/*******************************************************************************
* 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 com.google.common.collect.Lists;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import cuchaz.enigma.Enigma;
import cuchaz.enigma.EnigmaProfile;
import cuchaz.enigma.EnigmaProject;
import cuchaz.enigma.analysis.*;
import cuchaz.enigma.api.service.ObfuscationTestService;
import cuchaz.enigma.bytecode.translators.SourceFixVisitor;
import cuchaz.enigma.config.Config;
import cuchaz.enigma.gui.dialog.ProgressDialog;
import cuchaz.enigma.gui.stats.StatsGenerator;
import cuchaz.enigma.gui.stats.StatsMember;
import cuchaz.enigma.gui.util.History;
import cuchaz.enigma.network.EnigmaClient;
import cuchaz.enigma.network.EnigmaServer;
import cuchaz.enigma.network.IntegratedEnigmaServer;
import cuchaz.enigma.network.ServerPacketHandler;
import cuchaz.enigma.network.packet.LoginC2SPacket;
import cuchaz.enigma.network.packet.Packet;
import cuchaz.enigma.source.*;
import cuchaz.enigma.throwables.MappingParseException;
import cuchaz.enigma.translation.Translator;
import cuchaz.enigma.translation.mapping.*;
import cuchaz.enigma.translation.mapping.serde.MappingFormat;
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.Message;
import cuchaz.enigma.utils.ReadableToken;
import cuchaz.enigma.utils.Utils;
import org.objectweb.asm.tree.ClassNode;
import javax.annotation.Nullable;
import javax.swing.JOptionPane;
import javax.swing.SwingUtilities;
import java.awt.*;
import java.awt.event.ItemEvent;
import java.io.*;
import java.nio.file.Path;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class GuiController {
private static final ExecutorService DECOMPILER_SERVICE = Executors.newSingleThreadExecutor(
new ThreadFactoryBuilder()
.setDaemon(true)
.setNameFormat("decompiler-thread")
.build()
);
private final Gui gui;
public final Enigma enigma;
public EnigmaProject project;
private DecompilerService decompilerService;
private Decompiler decompiler;
private IndexTreeBuilder indexTreeBuilder;
private Path loadedMappingPath;
private MappingFormat loadedMappingFormat;
private DecompiledClassSource currentSource;
private Source uncommentedSource;
private EnigmaClient client;
private EnigmaServer server;
public GuiController(Gui gui, EnigmaProfile profile) {
this.gui = gui;
this.enigma = Enigma.builder()
.setProfile(profile)
.build();
decompilerService = Config.getInstance().decompiler.service;
}
public boolean isDirty() {
return project != null && project.getMapper().isDirty();
}
public CompletableFuture openJar(final Path jarPath) {
this.gui.onStartOpenJar();
return ProgressDialog.runOffThread(gui.getFrame(), progress -> {
project = enigma.openJar(jarPath, progress);
indexTreeBuilder = new IndexTreeBuilder(project.getJarIndex());
decompiler = createDecompiler();
gui.onFinishOpenJar(jarPath.getFileName().toString());
refreshClasses();
});
}
private Decompiler createDecompiler() {
return decompilerService.create(name -> {
ClassNode node = project.getClassCache().getClassNode(name);
if (node == null) {
return null;
}
ClassNode fixedNode = new ClassNode();
node.accept(new SourceFixVisitor(Utils.ASM_VERSION, fixedNode, project.getJarIndex()));
return fixedNode;
}, new SourceSettings(true, true));
}
public void closeJar() {
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();
EntryTree mappings = format.read(path, progress, saveParameters);
project.setMappings(mappings);
loadedMappingFormat = format;
loadedMappingPath = path;
refreshClasses();
refreshCurrentClass();
} catch (MappingParseException e) {
JOptionPane.showMessageDialog(gui.getFrame(), e.getMessage());
}
});
}
public void openMappings(EntryTree mappings) {
if (project == null) return;
project.setMappings(mappings);
refreshClasses();
refreshCurrentClass();
}
public CompletableFuture saveMappings(Path path) {
return saveMappings(path, loadedMappingFormat);
}
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();
refreshCurrentClass();
}
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);
EnigmaProject.SourceExport source = jar.decompile(progress, decompilerService);
source.write(path, progress);
});
}
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 Token getToken(int pos) {
if (this.currentSource == null) {
return null;
}
return this.currentSource.getIndex().getReferenceToken(pos);
}
@Nullable
public EntryReference, Entry>> getReference(Token token) {
if (this.currentSource == null) {
return null;
}
return this.currentSource.getIndex().getReference(token);
}
public ReadableToken getReadableToken(Token token) {
if (this.currentSource == null) {
return null;
}
SourceIndex index = this.currentSource.getIndex();
return new ReadableToken(
index.getLineNumber(token.start),
index.getColumnNumber(token.start),
index.getColumnNumber(token.end)
);
}
/**
* 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(new EntryReference<>(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.gui.referenceHistory == null) {
this.gui.referenceHistory = new History<>(reference);
} else {
if (!reference.equals(this.gui.referenceHistory.getCurrent())) {
this.gui.referenceHistory.push(reference);
}
}
setReference(reference);
}
/**
* Navigates to the reference without modifying history. If the class is not currently loaded, it will be loaded.
*
* @param reference the reference
*/
private void setReference(EntryReference, Entry>> reference) {
// get the reference target class
ClassEntry classEntry = reference.getLocationClassEntry();
if (!project.isRenamable(classEntry)) {
throw new IllegalArgumentException("Obfuscated class " + classEntry + " was not found in the jar!");
}
if (this.currentSource == null || !this.currentSource.getEntry().equals(classEntry)) {
// deobfuscate the class, then navigate to the reference
loadClass(classEntry, () -> showReference(reference));
} else {
showReference(reference);
}
}
/**
* Navigates to the reference without modifying history. Assumes the class is loaded.
*
* @param reference
*/
private void showReference(EntryReference, Entry>> reference) {
Collection tokens = getTokensForReference(reference);
if (tokens.isEmpty()) {
// DEBUG
System.err.println(String.format("WARNING: no tokens found for %s in %s", reference, this.currentSource.getEntry()));
} else {
this.gui.showTokens(tokens);
}
}
public Collection getTokensForReference(EntryReference, Entry>> reference) {
EntryRemapper mapper = this.project.getMapper();
SourceIndex index = this.currentSource.getIndex();
return mapper.getObfResolver().resolveReference(reference, ResolutionStrategy.RESOLVE_CLOSEST)
.stream()
.flatMap(r -> index.getReferenceTokens(r).stream())
.collect(Collectors.toList());
}
public void openPreviousReference() {
if (hasPreviousReference()) {
setReference(gui.referenceHistory.goBack());
}
}
public boolean hasPreviousReference() {
return gui.referenceHistory != null && gui.referenceHistory.canGoBack();
}
public void openNextReference() {
if (hasNextReference()) {
setReference(gui.referenceHistory.goForward());
}
}
public boolean hasNextReference() {
return gui.referenceHistory != null && gui.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);
}
private void refreshClasses() {
List obfClasses = Lists.newArrayList();
List deobfClasses = Lists.newArrayList();
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 -> {
ClassEntry deobfEntry = mapper.deobfuscate(entry);
List obfService = enigma.getServices().get(ObfuscationTestService.TYPE);
boolean obfuscated = 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 void refreshCurrentClass() {
refreshCurrentClass(null);
}
private void refreshCurrentClass(EntryReference, Entry>> reference) {
refreshCurrentClass(reference, RefreshMode.MINIMAL);
}
private void refreshCurrentClass(EntryReference, Entry>> reference, RefreshMode mode) {
if (currentSource != null) {
if (reference == null) {
int obfSelectionStart = currentSource.getObfuscatedOffset(gui.editor.getSelectionStart());
int obfSelectionEnd = currentSource.getObfuscatedOffset(gui.editor.getSelectionEnd());
Rectangle viewportBounds = gui.sourceScroller.getViewport().getViewRect();
// Here we pick an "anchor position", which we want to stay in the same vertical location on the screen after the new text has been set
int anchorModelPos = gui.editor.getSelectionStart();
Rectangle anchorViewPos = Utils.safeModelToView(gui.editor, anchorModelPos);
if (anchorViewPos.y < viewportBounds.y || anchorViewPos.y >= viewportBounds.y + viewportBounds.height) {
anchorModelPos = gui.editor.viewToModel(new Point(0, viewportBounds.y));
anchorViewPos = Utils.safeModelToView(gui.editor, anchorModelPos);
}
int obfAnchorPos = currentSource.getObfuscatedOffset(anchorModelPos);
Rectangle anchorViewPos_f = anchorViewPos;
int scrollX = gui.sourceScroller.getHorizontalScrollBar().getValue();
loadClass(currentSource.getEntry(), () -> SwingUtilities.invokeLater(() -> {
int newAnchorModelPos = currentSource.getDeobfuscatedOffset(obfAnchorPos);
Rectangle newAnchorViewPos = Utils.safeModelToView(gui.editor, newAnchorModelPos);
int newScrollY = newAnchorViewPos.y - (anchorViewPos_f.y - viewportBounds.y);
gui.editor.select(currentSource.getDeobfuscatedOffset(obfSelectionStart), currentSource.getDeobfuscatedOffset(obfSelectionEnd));
// Changing the selection scrolls to the caret position inside a SwingUtilities.invokeLater call, so
// we need to wrap our change to the scroll position inside another invokeLater so it happens after
// the caret's own scrolling.
SwingUtilities.invokeLater(() -> {
gui.sourceScroller.getHorizontalScrollBar().setValue(Math.min(scrollX, gui.sourceScroller.getHorizontalScrollBar().getMaximum()));
gui.sourceScroller.getVerticalScrollBar().setValue(Math.min(newScrollY, gui.sourceScroller.getVerticalScrollBar().getMaximum()));
});
}), mode);
} else {
loadClass(currentSource.getEntry(), () -> showReference(reference), mode);
}
}
}
private void loadClass(ClassEntry classEntry, Runnable callback) {
loadClass(classEntry, callback, RefreshMode.MINIMAL);
}
private void loadClass(ClassEntry classEntry, Runnable callback, RefreshMode mode) {
ClassEntry targetClass = classEntry.getOutermostClass();
boolean requiresDecompile = mode == RefreshMode.FULL || currentSource == null || !currentSource.getEntry().equals(targetClass);
if (requiresDecompile) {
currentSource = null; // Or the GUI may try to find a nonexistent token
gui.setEditorText(I18n.translate("info_panel.editor.class.decompiling"));
}
DECOMPILER_SERVICE.submit(() -> {
try {
if (requiresDecompile || mode == RefreshMode.JAVADOCS) {
currentSource = decompileSource(targetClass, mode == RefreshMode.JAVADOCS);
}
remapSource(project.getMapper().getDeobfuscator());
callback.run();
} catch (Throwable t) {
System.err.println("An exception was thrown while decompiling class " + classEntry.getFullName());
t.printStackTrace(System.err);
}
});
}
private DecompiledClassSource decompileSource(ClassEntry targetClass, boolean onlyRefreshJavadocs) {
try {
if (!onlyRefreshJavadocs || currentSource == null || !currentSource.getEntry().equals(targetClass)) {
uncommentedSource = decompiler.getSource(targetClass.getFullName());
}
Source source = uncommentedSource.addJavadocs(project.getMapper());
if (source == null) {
gui.setEditorText(I18n.translate("info_panel.editor.class.not_found") + " " + targetClass);
return DecompiledClassSource.text(targetClass, "Unable to find class");
}
SourceIndex index = source.index();
index.resolveReferences(project.getMapper().getObfResolver());
return new DecompiledClassSource(targetClass, index);
} catch (Throwable t) {
StringWriter traceWriter = new StringWriter();
t.printStackTrace(new PrintWriter(traceWriter));
return DecompiledClassSource.text(targetClass, traceWriter.toString());
}
}
private void remapSource(Translator translator) {
if (currentSource == null) {
return;
}
currentSource.remapSource(project, translator);
gui.setEditorTheme(Config.getInstance().lookAndFeel);
gui.setSource(currentSource);
}
public void modifierChange(ItemEvent event) {
if (event.getStateChange() == ItemEvent.SELECTED) {
EntryRemapper mapper = project.getMapper();
Entry> entry = gui.cursorReference.entry;
AccessModifier modifier = (AccessModifier) event.getItem();
EntryMapping mapping = mapper.getDeobfMapping(entry);
if (mapping != null) {
mapper.mapFromObf(entry, new EntryMapping(mapping.getTargetName(), modifier));
} else {
mapper.mapFromObf(entry, new EntryMapping(entry.getName(), modifier));
}
refreshCurrentClass();
}
}
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;
}
public void rename(EntryReference, Entry>> reference, String newName, boolean refreshClassTree) {
rename(reference, newName, refreshClassTree, true);
}
public void rename(EntryReference, Entry>> reference, String newName, boolean refreshClassTree, boolean jumpToReference) {
Entry> entry = reference.getNameableEntry();
project.getMapper().mapFromObf(entry, new EntryMapping(newName));
if (refreshClassTree && reference.entry instanceof ClassEntry && !((ClassEntry) reference.entry).isInnerClass())
this.gui.moveClassTree(reference, newName);
refreshCurrentClass(jumpToReference ? reference : null);
}
public void removeMapping(EntryReference, Entry>> reference) {
removeMapping(reference, true);
}
public void removeMapping(EntryReference, Entry>> reference, boolean jumpToReference) {
project.getMapper().removeByObf(reference.getNameableEntry());
if (reference.entry instanceof ClassEntry)
this.gui.moveClassTree(reference, false, true);
refreshCurrentClass(jumpToReference ? reference : null);
}
public void changeDocs(EntryReference, Entry>> reference, String updatedDocs) {
changeDocs(reference, updatedDocs, true);
}
public void changeDocs(EntryReference, Entry>> reference, String updatedDocs, boolean jumpToReference) {
changeDoc(reference.entry, Utils.isBlank(updatedDocs) ? null : updatedDocs);
refreshCurrentClass(jumpToReference ? reference : null, RefreshMode.JAVADOCS);
}
private void changeDoc(Entry> obfEntry, String newDoc) {
EntryRemapper mapper = project.getMapper();
if (mapper.getDeobfMapping(obfEntry) == null) {
markAsDeobfuscated(obfEntry, false); // NPE
}
mapper.mapFromObf(obfEntry, mapper.getDeobfMapping(obfEntry).withDocs(newDoc), false);
}
private void markAsDeobfuscated(Entry> obfEntry, boolean renaming) {
EntryRemapper mapper = project.getMapper();
mapper.mapFromObf(obfEntry, new EntryMapping(mapper.deobfuscate(obfEntry).getName()), renaming);
}
public void markAsDeobfuscated(EntryReference, Entry>> reference) {
markAsDeobfuscated(reference, true);
}
public void markAsDeobfuscated(EntryReference, Entry>> reference, boolean jumpToReference) {
EntryRemapper mapper = project.getMapper();
Entry> entry = reference.getNameableEntry();
mapper.mapFromObf(entry, new EntryMapping(mapper.deobfuscate(entry).getName()));
if (reference.entry instanceof ClassEntry && !((ClassEntry) reference.entry).isInnerClass())
this.gui.moveClassTree(reference, true, false);
refreshCurrentClass(jumpToReference ? reference : null);
}
public void openStats(Set includedMembers) {
ProgressDialog.runOffThread(gui.getFrame(), progress -> {
String data = new StatsGenerator(project).generate(progress, includedMembers);
try {
File statsFile = File.createTempFile("stats", ".html");
try (FileWriter w = new FileWriter(statsFile)) {
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) {
uncommentedSource = null;
decompilerService = service;
decompiler = createDecompiler();
refreshCurrentClass(null, RefreshMode.FULL);
}
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, EnigmaServer.OWNER_USERNAME));
gui.setConnectionState(ConnectionState.HOSTING);
}
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);
});
}
public void sendPacket(Packet packet) {
if (client != null) {
client.sendPacket(packet);
}
}
public void addMessage(Message message) {
gui.addMessage(message);
}
public void updateUserList(List users) {
gui.setUserList(users);
}
}