From 854f4d49407e45d67dd5754afd21a7e59970ca5b Mon Sep 17 00:00:00 2001 From: Joseph Burton Date: Sun, 3 May 2020 21:06:38 +0100 Subject: Multiplayer support (#221) * First pass on multiplayer * Apply review suggestions * Dedicated Enigma server * Don't jump to references when other users do stuff * Better UI + translations * french translation * Apply review suggestions * Document the protocol * Fix most issues with scrolling. * Apply review suggestions * Fix zip hash issues + add a bit more logging * Optimize zip hash * Fix a couple of login bugs * Add message log and user list * Make Message an abstract class * Make status bar work, add chat box * Hide message log/users list when not connected * Fix status bar not resetting entirely * Run stop server task on server thread to prevent multithreading race conditions * Add c2s message to packet id list * Fix message scroll bar not scrolling to the end * Formatting * User list size -> ushort * Combine contains and remove check * Check removal before sending packet * Add password to login packet * Fix the GUI closing the rename text field when someone else renames something * Update fr_fr.json * oups * Make connection/server create dialogs not useless if it fails once * Refactor UI state updating * Fix imports * Fix Collab menu * Fix NPE when rename not allowed * Make the log file a configurable option * Don't use modified UTF * Update fr_fr.json * Bump version to 0.15.4 * Apparently I can't spell neither words nor semantic versions Co-authored-by: Yanis48 Co-authored-by: 2xsaiko --- src/main/java/cuchaz/enigma/Enigma.java | 3 +- src/main/java/cuchaz/enigma/EnigmaProfile.java | 22 ++ src/main/java/cuchaz/enigma/EnigmaProject.java | 11 +- src/main/java/cuchaz/enigma/Main.java | 22 +- .../java/cuchaz/enigma/gui/ConnectionState.java | 7 + .../cuchaz/enigma/gui/DecompiledClassSource.java | 26 ++ src/main/java/cuchaz/enigma/gui/Gui.java | 193 ++++++++-- src/main/java/cuchaz/enigma/gui/GuiController.java | 150 +++++++- .../cuchaz/enigma/gui/MessageListCellRenderer.java | 24 ++ .../enigma/gui/dialog/ConnectToServerDialog.java | 82 +++++ .../enigma/gui/dialog/CreateServerDialog.java | 65 ++++ .../enigma/gui/elements/CollapsibleTabbedPane.java | 40 +++ .../java/cuchaz/enigma/gui/elements/MenuBar.java | 79 ++++- .../enigma/network/DedicatedEnigmaServer.java | 164 +++++++++ .../java/cuchaz/enigma/network/EnigmaClient.java | 85 +++++ .../java/cuchaz/enigma/network/EnigmaServer.java | 292 +++++++++++++++ .../enigma/network/IntegratedEnigmaServer.java | 16 + .../cuchaz/enigma/network/ServerPacketHandler.java | 22 ++ .../enigma/network/packet/ChangeDocsC2SPacket.java | 59 ++++ .../enigma/network/packet/ChangeDocsS2CPacket.java | 44 +++ .../network/packet/ConfirmChangeC2SPacket.java | 33 ++ .../enigma/network/packet/KickS2CPacket.java | 33 ++ .../enigma/network/packet/LoginC2SPacket.java | 75 ++++ .../network/packet/MarkDeobfuscatedC2SPacket.java | 48 +++ .../network/packet/MarkDeobfuscatedS2CPacket.java | 40 +++ .../enigma/network/packet/MessageC2SPacket.java | 39 ++ .../enigma/network/packet/MessageS2CPacket.java | 36 ++ .../java/cuchaz/enigma/network/packet/Packet.java | 15 + .../cuchaz/enigma/network/packet/PacketHelper.java | 135 +++++++ .../enigma/network/packet/PacketRegistry.java | 64 ++++ .../network/packet/RemoveMappingC2SPacket.java | 55 +++ .../network/packet/RemoveMappingS2CPacket.java | 40 +++ .../enigma/network/packet/RenameC2SPacket.java | 64 ++++ .../enigma/network/packet/RenameS2CPacket.java | 48 +++ .../network/packet/SyncMappingsS2CPacket.java | 88 +++++ .../enigma/network/packet/UserListS2CPacket.java | 44 +++ src/main/java/cuchaz/enigma/utils/Message.java | 392 +++++++++++++++++++++ src/main/java/cuchaz/enigma/utils/Utils.java | 64 +++- src/main/resources/lang/en_us.json | 38 ++ src/main/resources/lang/fr_fr.json | 38 ++ 40 files changed, 2718 insertions(+), 77 deletions(-) create mode 100644 src/main/java/cuchaz/enigma/gui/ConnectionState.java create mode 100644 src/main/java/cuchaz/enigma/gui/MessageListCellRenderer.java create mode 100644 src/main/java/cuchaz/enigma/gui/dialog/ConnectToServerDialog.java create mode 100644 src/main/java/cuchaz/enigma/gui/dialog/CreateServerDialog.java create mode 100644 src/main/java/cuchaz/enigma/gui/elements/CollapsibleTabbedPane.java create mode 100644 src/main/java/cuchaz/enigma/network/DedicatedEnigmaServer.java create mode 100644 src/main/java/cuchaz/enigma/network/EnigmaClient.java create mode 100644 src/main/java/cuchaz/enigma/network/EnigmaServer.java create mode 100644 src/main/java/cuchaz/enigma/network/IntegratedEnigmaServer.java create mode 100644 src/main/java/cuchaz/enigma/network/ServerPacketHandler.java create mode 100644 src/main/java/cuchaz/enigma/network/packet/ChangeDocsC2SPacket.java create mode 100644 src/main/java/cuchaz/enigma/network/packet/ChangeDocsS2CPacket.java create mode 100644 src/main/java/cuchaz/enigma/network/packet/ConfirmChangeC2SPacket.java create mode 100644 src/main/java/cuchaz/enigma/network/packet/KickS2CPacket.java create mode 100644 src/main/java/cuchaz/enigma/network/packet/LoginC2SPacket.java create mode 100644 src/main/java/cuchaz/enigma/network/packet/MarkDeobfuscatedC2SPacket.java create mode 100644 src/main/java/cuchaz/enigma/network/packet/MarkDeobfuscatedS2CPacket.java create mode 100644 src/main/java/cuchaz/enigma/network/packet/MessageC2SPacket.java create mode 100644 src/main/java/cuchaz/enigma/network/packet/MessageS2CPacket.java create mode 100644 src/main/java/cuchaz/enigma/network/packet/Packet.java create mode 100644 src/main/java/cuchaz/enigma/network/packet/PacketHelper.java create mode 100644 src/main/java/cuchaz/enigma/network/packet/PacketRegistry.java create mode 100644 src/main/java/cuchaz/enigma/network/packet/RemoveMappingC2SPacket.java create mode 100644 src/main/java/cuchaz/enigma/network/packet/RemoveMappingS2CPacket.java create mode 100644 src/main/java/cuchaz/enigma/network/packet/RenameC2SPacket.java create mode 100644 src/main/java/cuchaz/enigma/network/packet/RenameS2CPacket.java create mode 100644 src/main/java/cuchaz/enigma/network/packet/SyncMappingsS2CPacket.java create mode 100644 src/main/java/cuchaz/enigma/network/packet/UserListS2CPacket.java create mode 100644 src/main/java/cuchaz/enigma/utils/Message.java (limited to 'src/main') diff --git a/src/main/java/cuchaz/enigma/Enigma.java b/src/main/java/cuchaz/enigma/Enigma.java index b8887c2..f5f0649 100644 --- a/src/main/java/cuchaz/enigma/Enigma.java +++ b/src/main/java/cuchaz/enigma/Enigma.java @@ -21,6 +21,7 @@ import cuchaz.enigma.api.service.EnigmaService; import cuchaz.enigma.api.service.EnigmaServiceFactory; import cuchaz.enigma.api.service.EnigmaServiceType; import cuchaz.enigma.api.service.JarIndexerService; +import cuchaz.enigma.utils.Utils; import java.io.IOException; import java.nio.file.Path; @@ -50,7 +51,7 @@ public class Enigma { services.get(JarIndexerService.TYPE).forEach(indexer -> indexer.acceptJar(classCache, jarIndex)); - return new EnigmaProject(this, classCache, jarIndex); + return new EnigmaProject(this, classCache, jarIndex, Utils.zipSha1(path)); } public EnigmaProfile getProfile() { diff --git a/src/main/java/cuchaz/enigma/EnigmaProfile.java b/src/main/java/cuchaz/enigma/EnigmaProfile.java index 5a68be1..09b90f5 100644 --- a/src/main/java/cuchaz/enigma/EnigmaProfile.java +++ b/src/main/java/cuchaz/enigma/EnigmaProfile.java @@ -14,8 +14,15 @@ import cuchaz.enigma.api.service.EnigmaServiceType; import cuchaz.enigma.translation.mapping.MappingFileNameFormat; import cuchaz.enigma.translation.mapping.MappingSaveParameters; +import javax.annotation.Nullable; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; import java.io.Reader; import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Collections; import java.util.List; import java.util.Map; @@ -41,6 +48,21 @@ public final class EnigmaProfile { this.serviceProfiles = serviceProfiles; } + public static EnigmaProfile read(@Nullable Path file) throws IOException { + if (file != null) { + try (BufferedReader reader = Files.newBufferedReader(file)) { + return EnigmaProfile.parse(reader); + } + } else { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(Main.class.getResourceAsStream("/profile.json"), StandardCharsets.UTF_8))) { + return EnigmaProfile.parse(reader); + } catch (IOException ex) { + System.err.println("Failed to load default profile, will use empty profile: " + ex.getMessage()); + return EnigmaProfile.EMPTY; + } + } + } + public static EnigmaProfile parse(Reader reader) { return GSON.fromJson(reader, EnigmaProfile.class); } diff --git a/src/main/java/cuchaz/enigma/EnigmaProject.java b/src/main/java/cuchaz/enigma/EnigmaProject.java index 852bfc4..b345fb3 100644 --- a/src/main/java/cuchaz/enigma/EnigmaProject.java +++ b/src/main/java/cuchaz/enigma/EnigmaProject.java @@ -1,12 +1,14 @@ package cuchaz.enigma; import com.google.common.base.Functions; +import com.google.common.base.Preconditions; import cuchaz.enigma.analysis.ClassCache; import cuchaz.enigma.analysis.EntryReference; import cuchaz.enigma.analysis.index.JarIndex; import cuchaz.enigma.api.service.NameProposalService; import cuchaz.enigma.bytecode.translators.SourceFixVisitor; import cuchaz.enigma.bytecode.translators.TranslationClassVisitor; +import cuchaz.enigma.network.EnigmaServer; import cuchaz.enigma.source.*; import cuchaz.enigma.translation.Translator; import cuchaz.enigma.translation.mapping.*; @@ -39,13 +41,16 @@ public class EnigmaProject { private final ClassCache classCache; private final JarIndex jarIndex; + private final byte[] jarChecksum; private EntryRemapper mapper; - public EnigmaProject(Enigma enigma, ClassCache classCache, JarIndex jarIndex) { + public EnigmaProject(Enigma enigma, ClassCache classCache, JarIndex jarIndex, byte[] jarChecksum) { + Preconditions.checkArgument(jarChecksum.length == EnigmaServer.CHECKSUM_SIZE); this.enigma = enigma; this.classCache = classCache; this.jarIndex = jarIndex; + this.jarChecksum = jarChecksum; this.mapper = EntryRemapper.empty(jarIndex); } @@ -70,6 +75,10 @@ public class EnigmaProject { return jarIndex; } + public byte[] getJarChecksum() { + return jarChecksum; + } + public EntryRemapper getMapper() { return mapper; } diff --git a/src/main/java/cuchaz/enigma/Main.java b/src/main/java/cuchaz/enigma/Main.java index 1d63ec1..7c87669 100644 --- a/src/main/java/cuchaz/enigma/Main.java +++ b/src/main/java/cuchaz/enigma/Main.java @@ -17,10 +17,7 @@ import cuchaz.enigma.translation.mapping.serde.MappingFormat; import joptsimple.*; -import java.io.BufferedReader; import java.io.IOException; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -54,20 +51,7 @@ public class Main { return; } - EnigmaProfile parsedProfile; - if (options.has(profile)) { - Path profilePath = options.valueOf(profile); - try (BufferedReader reader = Files.newBufferedReader(profilePath)) { - parsedProfile = EnigmaProfile.parse(reader); - } - } else { - try (BufferedReader reader = new BufferedReader(new InputStreamReader(Main.class.getResourceAsStream("/profile.json"), StandardCharsets.UTF_8))){ - parsedProfile = EnigmaProfile.parse(reader); - } catch (IOException ex) { - System.out.println("Failed to load default profile, will use empty profile: " + ex.getMessage()); - parsedProfile = EnigmaProfile.EMPTY; - } - } + EnigmaProfile parsedProfile = EnigmaProfile.read(options.valueOf(profile)); Gui gui = new Gui(parsedProfile); GuiController controller = gui.getController(); @@ -95,8 +79,8 @@ public class Main { } } - private static class PathConverter implements ValueConverter { - static final ValueConverter INSTANCE = new PathConverter(); + public static class PathConverter implements ValueConverter { + public static final ValueConverter INSTANCE = new PathConverter(); PathConverter() { } diff --git a/src/main/java/cuchaz/enigma/gui/ConnectionState.java b/src/main/java/cuchaz/enigma/gui/ConnectionState.java new file mode 100644 index 0000000..db6590d --- /dev/null +++ b/src/main/java/cuchaz/enigma/gui/ConnectionState.java @@ -0,0 +1,7 @@ +package cuchaz.enigma.gui; + +public enum ConnectionState { + NOT_CONNECTED, + HOSTING, + CONNECTED, +} diff --git a/src/main/java/cuchaz/enigma/gui/DecompiledClassSource.java b/src/main/java/cuchaz/enigma/gui/DecompiledClassSource.java index f7097f0..08df3e7 100644 --- a/src/main/java/cuchaz/enigma/gui/DecompiledClassSource.java +++ b/src/main/java/cuchaz/enigma/gui/DecompiledClassSource.java @@ -126,6 +126,32 @@ public class DecompiledClassSource { highlightedTokens.computeIfAbsent(highlightType, t -> new ArrayList<>()).add(token); } + public int getObfuscatedOffset(int deobfOffset) { + return getOffset(remappedIndex, obfuscatedIndex, deobfOffset); + } + + public int getDeobfuscatedOffset(int obfOffset) { + return getOffset(obfuscatedIndex, remappedIndex, obfOffset); + } + + private static int getOffset(SourceIndex fromIndex, SourceIndex toIndex, int fromOffset) { + int relativeOffset = 0; + + Iterator fromTokenItr = fromIndex.referenceTokens().iterator(); + Iterator toTokenItr = toIndex.referenceTokens().iterator(); + while (fromTokenItr.hasNext() && toTokenItr.hasNext()) { + Token fromToken = fromTokenItr.next(); + Token toToken = toTokenItr.next(); + if (fromToken.end > fromOffset) { + break; + } + + relativeOffset = toToken.end - fromToken.end; + } + + return fromOffset + relativeOffset; + } + @Override public String toString() { return remappedIndex.getSource(); diff --git a/src/main/java/cuchaz/enigma/gui/Gui.java b/src/main/java/cuchaz/enigma/gui/Gui.java index 3412cd5..3adabae 100644 --- a/src/main/java/cuchaz/enigma/gui/Gui.java +++ b/src/main/java/cuchaz/enigma/gui/Gui.java @@ -34,6 +34,7 @@ import cuchaz.enigma.config.Themes; import cuchaz.enigma.gui.dialog.CrashDialog; import cuchaz.enigma.gui.dialog.JavadocDialog; import cuchaz.enigma.gui.dialog.SearchDialog; +import cuchaz.enigma.gui.elements.CollapsibleTabbedPane; import cuchaz.enigma.gui.elements.MenuBar; import cuchaz.enigma.gui.elements.PopupMenuBar; import cuchaz.enigma.gui.filechooser.FileChooserAny; @@ -46,10 +47,12 @@ import cuchaz.enigma.gui.panels.PanelEditor; import cuchaz.enigma.gui.panels.PanelIdentifier; import cuchaz.enigma.gui.panels.PanelObf; import cuchaz.enigma.gui.util.History; +import cuchaz.enigma.network.packet.*; import cuchaz.enigma.throwables.IllegalNameException; import cuchaz.enigma.translation.mapping.*; import cuchaz.enigma.translation.representation.entry.*; import cuchaz.enigma.utils.I18n; +import cuchaz.enigma.utils.Message; import cuchaz.enigma.gui.util.ScaleUtil; import cuchaz.enigma.utils.Utils; import de.sciss.syntaxpane.DefaultSyntaxKit; @@ -63,8 +66,11 @@ public class Gui { private final MenuBar menuBar; // state public History, Entry>> referenceHistory; + public EntryReference, Entry> renamingReference; public EntryReference, Entry> cursorReference; private boolean shouldNavigateOnClick; + private ConnectionState connectionState; + private boolean isJarOpen; public FileDialog jarFileChooser; public FileDialog tinyMappingsFileChooser; @@ -76,6 +82,7 @@ public class Gui { private JFrame frame; public Config.LookAndFeel editorFeel; public PanelEditor editor; + public JScrollPane sourceScroller; private JPanel classesPanel; private JSplitPane splitClasses; private PanelIdentifier infoPanel; @@ -87,6 +94,20 @@ public class Gui { private JList tokens; private JTabbedPane tabs; + private JSplitPane splitRight; + private JSplitPane logSplit; + private CollapsibleTabbedPane logTabs; + private JList users; + private DefaultListModel userModel; + private JScrollPane messageScrollPane; + private JList messages; + private DefaultListModel messageModel; + private JTextField chatBox; + + private JPanel statusBar; + private JLabel connectionStatusLabel; + private JLabel statusLabel; + public JTextField renameTextField; public JTextArea javadocTextArea; @@ -150,7 +171,7 @@ public class Gui { // init editor selectionHighlightPainter = new SelectionHighlightPainter(); this.editor = new PanelEditor(this); - JScrollPane sourceScroller = new JScrollPane(this.editor); + this.sourceScroller = new JScrollPane(this.editor); this.editor.setContentType("text/enigma-sources"); this.editor.setBackground(new Color(Config.getInstance().editorBackground)); DefaultSyntaxKit kit = (DefaultSyntaxKit) this.editor.getEditorKit(); @@ -283,7 +304,34 @@ public class Gui { tabs.addTab(I18n.translate("info_panel.tree.inheritance"), inheritancePanel); tabs.addTab(I18n.translate("info_panel.tree.implementations"), implementationsPanel); tabs.addTab(I18n.translate("info_panel.tree.calls"), callPanel); - JSplitPane splitRight = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, true, centerPanel, tabs); + logTabs = new CollapsibleTabbedPane(JTabbedPane.BOTTOM); + userModel = new DefaultListModel<>(); + users = new JList<>(userModel); + messageModel = new DefaultListModel<>(); + messages = new JList<>(messageModel); + messages.setCellRenderer(new MessageListCellRenderer()); + JPanel messagePanel = new JPanel(new BorderLayout()); + messageScrollPane = new JScrollPane(this.messages); + messagePanel.add(messageScrollPane, BorderLayout.CENTER); + JPanel chatPanel = new JPanel(new BorderLayout()); + chatBox = new JTextField(); + AbstractAction sendListener = new AbstractAction("Send") { + @Override + public void actionPerformed(ActionEvent e) { + sendMessage(); + } + }; + chatBox.addActionListener(sendListener); + JButton chatSendButton = new JButton(sendListener); + chatPanel.add(chatBox, BorderLayout.CENTER); + chatPanel.add(chatSendButton, BorderLayout.EAST); + messagePanel.add(chatPanel, BorderLayout.SOUTH); + logTabs.addTab(I18n.translate("log_panel.users"), new JScrollPane(this.users)); + logTabs.addTab(I18n.translate("log_panel.messages"), messagePanel); + logSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT, true, tabs, logTabs); + logSplit.setResizeWeight(0.5); + logSplit.resetToPreferredSizes(); + splitRight = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, true, centerPanel, this.logSplit); splitRight.setResizeWeight(1); // let the left side take all the slack splitRight.resetToPreferredSizes(); JSplitPane splitCenter = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, true, this.classesPanel, splitRight); @@ -294,7 +342,17 @@ public class Gui { this.menuBar = new MenuBar(this); this.frame.setJMenuBar(this.menuBar); + // init status bar + statusBar = new JPanel(new BorderLayout()); + statusBar.setBorder(BorderFactory.createLoweredBevelBorder()); + connectionStatusLabel = new JLabel(); + statusLabel = new JLabel(); + statusBar.add(statusLabel, BorderLayout.CENTER); + statusBar.add(connectionStatusLabel, BorderLayout.EAST); + pane.add(statusBar, BorderLayout.SOUTH); + // init state + setConnectionState(ConnectionState.NOT_CONNECTED); onCloseJar(); this.frame.addWindowListener(new WindowAdapter() { @@ -334,18 +392,14 @@ public class Gui { setEditorText(null); // update menu - this.menuBar.closeJarMenu.setEnabled(true); - this.menuBar.openMappingsMenus.forEach(item -> item.setEnabled(true)); - this.menuBar.saveMappingsMenu.setEnabled(false); - this.menuBar.saveMappingsMenus.forEach(item -> item.setEnabled(true)); - this.menuBar.closeMappingsMenu.setEnabled(true); - this.menuBar.exportSourceMenu.setEnabled(true); - this.menuBar.exportJarMenu.setEnabled(true); + isJarOpen = true; + updateUiState(); redraw(); } public void onCloseJar() { + // update gui this.frame.setTitle(Constants.NAME); setObfClasses(null); @@ -354,14 +408,10 @@ public class Gui { this.classesPanel.removeAll(); // update menu - this.menuBar.closeJarMenu.setEnabled(false); - this.menuBar.openMappingsMenus.forEach(item -> item.setEnabled(false)); - this.menuBar.saveMappingsMenu.setEnabled(false); - this.menuBar.saveMappingsMenus.forEach(item -> item.setEnabled(false)); - this.menuBar.closeMappingsMenu.setEnabled(false); - this.menuBar.exportSourceMenu.setEnabled(false); - this.menuBar.exportJarMenu.setEnabled(false); + isJarOpen = false; + setMappingsFile(null); + updateUiState(); redraw(); } @@ -375,7 +425,7 @@ public class Gui { public void setMappingsFile(Path path) { this.enigmaMappingsFileChooser.setSelectedFile(path != null ? path.toFile() : null); - this.menuBar.saveMappingsMenu.setEnabled(path != null); + updateUiState(); } public void setEditorText(String source) { @@ -561,10 +611,12 @@ public class Gui { boolean isConstructorEntry = isToken && referenceEntry instanceof MethodEntry && ((MethodEntry) referenceEntry).isConstructor(); boolean isRenamable = isToken && this.controller.project.isRenamable(cursorReference); - if (isToken) { - showCursorReference(cursorReference); - } else { - infoPanel.clearReference(); + if (!isRenaming()) { + if (isToken) { + showCursorReference(cursorReference); + } else { + infoPanel.clearReference(); + } } this.popupMenu.renameMenu.setEnabled(isRenamable); @@ -586,6 +638,11 @@ public class Gui { } public void startDocChange() { + EntryReference, Entry> curReference = cursorReference; + if (isRenaming()) { + finishRename(false); + } + renamingReference = curReference; // init the text box javadocTextArea = new JTextArea(10, 40); @@ -603,7 +660,8 @@ public class Gui { String newName = javadocTextArea.getText(); if (saveName) { try { - this.controller.changeDocs(cursorReference, newName); + this.controller.changeDocs(renamingReference, newName); + this.controller.sendPacket(new ChangeDocsC2SPacket(renamingReference.getNameableEntry(), newName)); } catch (IllegalNameException ex) { javadocTextArea.setBorder(BorderFactory.createLineBorder(Color.red, 1)); javadocTextArea.setToolTipText(ex.getReason()); @@ -665,14 +723,19 @@ public class Gui { else renameTextField.selectAll(); + renamingReference = cursorReference; + redraw(); } private void finishRename(boolean saveName) { String newName = renameTextField.getText(); + if (saveName && newName != null && !newName.isEmpty()) { try { - this.controller.rename(cursorReference, newName, true); + this.controller.rename(renamingReference, newName, true); + this.controller.sendPacket(new RenameC2SPacket(renamingReference.getNameableEntry(), newName, true)); + renameTextField = null; } catch (IllegalNameException ex) { renameTextField.setBorder(BorderFactory.createLineBorder(Color.red, 1)); renameTextField.setToolTipText(ex.getReason()); @@ -681,18 +744,20 @@ public class Gui { return; } - // abort the rename - JPanel panel = (JPanel) infoPanel.getComponent(0); - panel.remove(panel.getComponentCount() - 1); - panel.add(Utils.unboldLabel(new JLabel(cursorReference.getNameableName(), JLabel.LEFT))); - renameTextField = null; + // abort the rename + showCursorReference(cursorReference); + this.editor.grabFocus(); redraw(); } + private boolean isRenaming() { + return renameTextField != null; + } + public void showInheritance() { if (cursorReference == null) { @@ -783,8 +848,10 @@ public class Gui { if (!Objects.equals(obfEntry, deobfEntry)) { this.controller.removeMapping(cursorReference); + this.controller.sendPacket(new RemoveMappingC2SPacket(cursorReference.getNameableEntry())); } else { this.controller.markAsDeobfuscated(cursorReference); + this.controller.sendPacket(new MarkDeobfuscatedC2SPacket(cursorReference.getNameableEntry())); } } @@ -850,6 +917,7 @@ public class Gui { ClassEntry prevDataChild = (ClassEntry) childNode.getUserObject(); ClassEntry dataChild = new ClassEntry(data + "/" + prevDataChild.getSimpleName()); this.controller.rename(new EntryReference<>(prevDataChild, prevDataChild.getFullName()), dataChild.getFullName(), false); + this.controller.sendPacket(new RenameC2SPacket(prevDataChild, dataChild.getFullName(), false)); childNode.setUserObject(dataChild); } node.setUserObject(data); @@ -857,8 +925,10 @@ public class Gui { this.deobfPanel.deobfClasses.reload(); } // class rename - else if (data instanceof ClassEntry) + else if (data instanceof ClassEntry) { this.controller.rename(new EntryReference<>((ClassEntry) prevData, ((ClassEntry) prevData).getFullName()), ((ClassEntry) data).getFullName(), false); + this.controller.sendPacket(new RenameC2SPacket((ClassEntry) prevData, ((ClassEntry) data).getFullName(), false)); + } } public void moveClassTree(EntryReference, Entry> obfReference, String newName) { @@ -920,4 +990,69 @@ public class Gui { return searchDialog; } + + public MenuBar getMenuBar() { + return menuBar; + } + + public void addMessage(Message message) { + JScrollBar verticalScrollBar = messageScrollPane.getVerticalScrollBar(); + boolean isAtBottom = verticalScrollBar.getValue() >= verticalScrollBar.getMaximum() - verticalScrollBar.getModel().getExtent(); + messageModel.addElement(message); + if (isAtBottom) { + SwingUtilities.invokeLater(() -> verticalScrollBar.setValue(verticalScrollBar.getMaximum() - verticalScrollBar.getModel().getExtent())); + } + statusLabel.setText(message.translate()); + } + + public void setUserList(List users) { + userModel.clear(); + users.forEach(userModel::addElement); + connectionStatusLabel.setText(String.format(I18n.translate("status.connected_user_count"), users.size())); + } + + private void sendMessage() { + String text = chatBox.getText().trim(); + if (!text.isEmpty()) { + getController().sendPacket(new MessageC2SPacket(text)); + } + chatBox.setText(""); + } + + /** + * Updates the state of the UI elements (button text, enabled state, ...) to reflect the current program state. + * This is a central place to update the UI state to prevent multiple code paths from changing the same state, + * causing inconsistencies. + */ + public void updateUiState() { + menuBar.connectToServerMenu.setEnabled(isJarOpen && connectionState != ConnectionState.HOSTING); + menuBar.connectToServerMenu.setText(I18n.translate(connectionState != ConnectionState.CONNECTED ? "menu.collab.connect" : "menu.collab.disconnect")); + menuBar.startServerMenu.setEnabled(isJarOpen && connectionState != ConnectionState.CONNECTED); + menuBar.startServerMenu.setText(I18n.translate(connectionState != ConnectionState.HOSTING ? "menu.collab.server.start" : "menu.collab.server.stop")); + + menuBar.closeJarMenu.setEnabled(isJarOpen); + menuBar.openMappingsMenus.forEach(item -> item.setEnabled(isJarOpen)); + menuBar.saveMappingsMenu.setEnabled(isJarOpen && enigmaMappingsFileChooser.getSelectedFile() != null && connectionState != ConnectionState.CONNECTED); + menuBar.saveMappingsMenus.forEach(item -> item.setEnabled(isJarOpen)); + menuBar.closeMappingsMenu.setEnabled(isJarOpen); + menuBar.exportSourceMenu.setEnabled(isJarOpen); + menuBar.exportJarMenu.setEnabled(isJarOpen); + + connectionStatusLabel.setText(I18n.translate(connectionState == ConnectionState.NOT_CONNECTED ? "status.disconnected" : "status.connected")); + + if (connectionState == ConnectionState.NOT_CONNECTED) { + logSplit.setLeftComponent(null); + splitRight.setRightComponent(tabs); + } else { + splitRight.setRightComponent(logSplit); + logSplit.setLeftComponent(tabs); + } + } + + public void setConnectionState(ConnectionState state) { + connectionState = state; + statusLabel.setText(I18n.translate("status.ready")); + updateUiState(); + } + } diff --git a/src/main/java/cuchaz/enigma/gui/GuiController.java b/src/main/java/cuchaz/enigma/gui/GuiController.java index 742d6b8..cccc9e8 100644 --- a/src/main/java/cuchaz/enigma/gui/GuiController.java +++ b/src/main/java/cuchaz/enigma/gui/GuiController.java @@ -24,24 +24,33 @@ 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 java.awt.Desktop; +import javax.swing.SwingUtilities; +import java.awt.*; import java.awt.event.ItemEvent; import java.io.*; import java.nio.file.Path; @@ -76,6 +85,9 @@ public class GuiController { 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() @@ -143,6 +155,14 @@ public class GuiController { }); } + public void openMappings(EntryTree mappings) { + if (project == null) return; + + project.setMappings(mappings); + refreshClasses(); + refreshCurrentClass(); + } + public CompletableFuture saveMappings(Path path) { return saveMappings(path, loadedMappingFormat); } @@ -388,11 +408,39 @@ public class GuiController { private void refreshCurrentClass(EntryReference, Entry> reference, RefreshMode mode) { if (currentSource != null) { - loadClass(currentSource.getEntry(), () -> { - if (reference != null) { - showReference(reference); + 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); } - }, mode); + 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); + } } } @@ -528,43 +576,59 @@ public class GuiController { } 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(reference); + 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(reference); + refreshCurrentClass(jumpToReference ? reference : null); } public void changeDocs(EntryReference, Entry> reference, String updatedDocs) { - changeDoc(reference.entry, updatedDocs); + changeDocs(reference, updatedDocs, true); + } - refreshCurrentClass(reference, RefreshMode.JAVADOCS); + public void changeDocs(EntryReference, Entry> reference, String updatedDocs, boolean jumpToReference) { + changeDoc(reference.entry, Utils.isBlank(updatedDocs) ? null : updatedDocs); + + refreshCurrentClass(jumpToReference ? reference : null, RefreshMode.JAVADOCS); } - public void changeDoc(Entry obfEntry, String newDoc) { + private void changeDoc(Entry obfEntry, String newDoc) { EntryRemapper mapper = project.getMapper(); if (mapper.getDeobfMapping(obfEntry) == null) { - markAsDeobfuscated(obfEntry,false); // NPE + markAsDeobfuscated(obfEntry, false); // NPE } mapper.mapFromObf(obfEntry, mapper.getDeobfMapping(obfEntry).withDocs(newDoc), false); } - public void markAsDeobfuscated(Entry obfEntry, boolean renaming) { + 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())); @@ -572,7 +636,7 @@ public class GuiController { if (reference.entry instanceof ClassEntry && !((ClassEntry) reference.entry).isInnerClass()) this.gui.moveClassTree(reference, true, false); - refreshCurrentClass(reference); + refreshCurrentClass(jumpToReference ? reference : null); } public void openStats(Set includedMembers) { @@ -602,4 +666,64 @@ public class GuiController { 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); + } + } diff --git a/src/main/java/cuchaz/enigma/gui/MessageListCellRenderer.java b/src/main/java/cuchaz/enigma/gui/MessageListCellRenderer.java new file mode 100644 index 0000000..c9e38cb --- /dev/null +++ b/src/main/java/cuchaz/enigma/gui/MessageListCellRenderer.java @@ -0,0 +1,24 @@ +package cuchaz.enigma.gui; + +import java.awt.Component; + +import javax.swing.DefaultListCellRenderer; +import javax.swing.JList; + +import cuchaz.enigma.utils.Message; + +// For now, just render the translated text. +// TODO: Icons or something later? +public class MessageListCellRenderer extends DefaultListCellRenderer { + + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { + super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + Message message = (Message) value; + if (message != null) { + setText(message.translate()); + } + return this; + } + +} diff --git a/src/main/java/cuchaz/enigma/gui/dialog/ConnectToServerDialog.java b/src/main/java/cuchaz/enigma/gui/dialog/ConnectToServerDialog.java new file mode 100644 index 0000000..c5f505c --- /dev/null +++ b/src/main/java/cuchaz/enigma/gui/dialog/ConnectToServerDialog.java @@ -0,0 +1,82 @@ +package cuchaz.enigma.gui.dialog; + +import cuchaz.enigma.network.EnigmaServer; +import cuchaz.enigma.utils.I18n; + +import javax.swing.*; +import java.awt.Frame; + +public class ConnectToServerDialog { + + public static Result show(Frame parentComponent) { + JTextField usernameField = new JTextField(System.getProperty("user.name"), 20); + JPanel usernameRow = new JPanel(); + usernameRow.add(new JLabel(I18n.translate("prompt.connect.username"))); + usernameRow.add(usernameField); + JTextField ipField = new JTextField(20); + JPanel ipRow = new JPanel(); + ipRow.add(new JLabel(I18n.translate("prompt.connect.ip"))); + ipRow.add(ipField); + JTextField portField = new JTextField(String.valueOf(EnigmaServer.DEFAULT_PORT), 10); + JPanel portRow = new JPanel(); + portRow.add(new JLabel(I18n.translate("prompt.port"))); + portRow.add(portField); + JPasswordField passwordField = new JPasswordField(20); + JPanel passwordRow = new JPanel(); + passwordRow.add(new JLabel(I18n.translate("prompt.password"))); + passwordRow.add(passwordField); + + int response = JOptionPane.showConfirmDialog(parentComponent, new Object[]{usernameRow, ipRow, portRow, passwordRow}, I18n.translate("prompt.connect.title"), JOptionPane.OK_CANCEL_OPTION); + if (response != JOptionPane.OK_OPTION) { + return null; + } + + String username = usernameField.getText(); + String ip = ipField.getText(); + int port; + try { + port = Integer.parseInt(portField.getText()); + } catch (NumberFormatException e) { + JOptionPane.showMessageDialog(parentComponent, I18n.translate("prompt.port.nan"), I18n.translate("prompt.connect.title"), JOptionPane.ERROR_MESSAGE); + return null; + } + if (port < 0 || port >= 65536) { + JOptionPane.showMessageDialog(parentComponent, I18n.translate("prompt.port.invalid"), I18n.translate("prompt.connect.title"), JOptionPane.ERROR_MESSAGE); + return null; + } + char[] password = passwordField.getPassword(); + + return new Result(username, ip, port, password); + } + + public static class Result { + private final String username; + private final String ip; + private final int port; + private final char[] password; + + public Result(String username, String ip, int port, char[] password) { + this.username = username; + this.ip = ip; + this.port = port; + this.password = password; + } + + public String getUsername() { + return username; + } + + public String getIp() { + return ip; + } + + public int getPort() { + return port; + } + + public char[] getPassword() { + return password; + } + } + +} diff --git a/src/main/java/cuchaz/enigma/gui/dialog/CreateServerDialog.java b/src/main/java/cuchaz/enigma/gui/dialog/CreateServerDialog.java new file mode 100644 index 0000000..eea1dff --- /dev/null +++ b/src/main/java/cuchaz/enigma/gui/dialog/CreateServerDialog.java @@ -0,0 +1,65 @@ +package cuchaz.enigma.gui.dialog; + +import cuchaz.enigma.network.EnigmaServer; +import cuchaz.enigma.utils.I18n; + +import javax.swing.*; +import java.awt.*; + +public class CreateServerDialog { + + public static Result show(Frame parentComponent) { + JTextField portField = new JTextField(String.valueOf(EnigmaServer.DEFAULT_PORT), 10); + JPanel portRow = new JPanel(); + portRow.add(new JLabel(I18n.translate("prompt.port"))); + portRow.add(portField); + JPasswordField passwordField = new JPasswordField(20); + JPanel passwordRow = new JPanel(); + passwordRow.add(new JLabel(I18n.translate("prompt.password"))); + passwordRow.add(passwordField); + + int response = JOptionPane.showConfirmDialog(parentComponent, new Object[]{portRow, passwordRow}, I18n.translate("prompt.create_server.title"), JOptionPane.OK_CANCEL_OPTION); + if (response != JOptionPane.OK_OPTION) { + return null; + } + + int port; + try { + port = Integer.parseInt(portField.getText()); + } catch (NumberFormatException e) { + JOptionPane.showMessageDialog(parentComponent, I18n.translate("prompt.port.nan"), I18n.translate("prompt.create_server.title"), JOptionPane.ERROR_MESSAGE); + return null; + } + if (port < 0 || port >= 65536) { + JOptionPane.showMessageDialog(parentComponent, I18n.translate("prompt.port.invalid"), I18n.translate("prompt.create_server.title"), JOptionPane.ERROR_MESSAGE); + return null; + } + + char[] password = passwordField.getPassword(); + if (password.length > EnigmaServer.MAX_PASSWORD_LENGTH) { + JOptionPane.showMessageDialog(parentComponent, I18n.translate("prompt.password.too_long"), I18n.translate("prompt.create_server.title"), JOptionPane.ERROR_MESSAGE); + return null; + } + + return new Result(port, password); + } + + public static class Result { + private final int port; + private final char[] password; + + public Result(int port, char[] password) { + this.port = port; + this.password = password; + } + + public int getPort() { + return port; + } + + public char[] getPassword() { + return password; + } + } + +} diff --git a/src/main/java/cuchaz/enigma/gui/elements/CollapsibleTabbedPane.java b/src/main/java/cuchaz/enigma/gui/elements/CollapsibleTabbedPane.java new file mode 100644 index 0000000..fb497b1 --- /dev/null +++ b/src/main/java/cuchaz/enigma/gui/elements/CollapsibleTabbedPane.java @@ -0,0 +1,40 @@ +package cuchaz.enigma.gui.elements; + +import java.awt.event.MouseEvent; + +import javax.swing.JTabbedPane; + +public class CollapsibleTabbedPane extends JTabbedPane { + + public CollapsibleTabbedPane() { + } + + public CollapsibleTabbedPane(int tabPlacement) { + super(tabPlacement); + } + + public CollapsibleTabbedPane(int tabPlacement, int tabLayoutPolicy) { + super(tabPlacement, tabLayoutPolicy); + } + + @Override + protected void processMouseEvent(MouseEvent e) { + int id = e.getID(); + if (id == MouseEvent.MOUSE_PRESSED) { + if (!isEnabled()) return; + int tabIndex = getUI().tabForCoordinate(this, e.getX(), e.getY()); + if (tabIndex >= 0 && isEnabledAt(tabIndex)) { + if (tabIndex == getSelectedIndex()) { + if (isFocusOwner() && isRequestFocusEnabled()) { + requestFocus(); + } else { + setSelectedIndex(-1); + } + return; + } + } + } + super.processMouseEvent(e); + } + +} diff --git a/src/main/java/cuchaz/enigma/gui/elements/MenuBar.java b/src/main/java/cuchaz/enigma/gui/elements/MenuBar.java index 8098178..f8e4f7e 100644 --- a/src/main/java/cuchaz/enigma/gui/elements/MenuBar.java +++ b/src/main/java/cuchaz/enigma/gui/elements/MenuBar.java @@ -1,5 +1,18 @@ package cuchaz.enigma.gui.elements; +import cuchaz.enigma.config.Config; +import cuchaz.enigma.config.Themes; +import cuchaz.enigma.gui.Gui; +import cuchaz.enigma.gui.dialog.AboutDialog; +import cuchaz.enigma.gui.dialog.ConnectToServerDialog; +import cuchaz.enigma.gui.dialog.CreateServerDialog; +import cuchaz.enigma.gui.dialog.SearchDialog; +import cuchaz.enigma.gui.stats.StatsMember; +import cuchaz.enigma.gui.util.ScaleUtil; +import cuchaz.enigma.translation.mapping.serde.MappingFormat; +import cuchaz.enigma.utils.I18n; +import cuchaz.enigma.utils.Pair; + import java.awt.Container; import java.awt.Desktop; import java.awt.FlowLayout; @@ -13,21 +26,11 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.*; +import java.util.List; import java.util.stream.Collectors; import java.util.stream.IntStream; - import javax.swing.*; -import cuchaz.enigma.config.Config; -import cuchaz.enigma.config.Themes; -import cuchaz.enigma.gui.Gui; -import cuchaz.enigma.gui.dialog.AboutDialog; -import cuchaz.enigma.gui.dialog.SearchDialog; -import cuchaz.enigma.gui.stats.StatsMember; -import cuchaz.enigma.gui.util.ScaleUtil; -import cuchaz.enigma.translation.mapping.serde.MappingFormat; -import cuchaz.enigma.utils.I18n; -import cuchaz.enigma.utils.Pair; import javax.swing.*; @@ -49,6 +52,8 @@ public class MenuBar extends JMenuBar { public final JMenuItem dropMappingsMenu; public final JMenuItem exportSourceMenu; public final JMenuItem exportJarMenu; + public final JMenuItem connectToServerMenu; + public final JMenuItem startServerMenu; private final Gui gui; public MenuBar(Gui gui) { @@ -342,6 +347,58 @@ public class MenuBar extends JMenuBar { } } + /* + * Collab menu + */ + { + JMenu menu = new JMenu(I18n.translate("menu.collab")); + this.add(menu); + { + JMenuItem item = new JMenuItem(I18n.translate("menu.collab.connect")); + menu.add(item); + item.addActionListener(event -> { + if (this.gui.getController().getClient() != null) { + this.gui.getController().disconnectIfConnected(null); + return; + } + ConnectToServerDialog.Result result = ConnectToServerDialog.show(this.gui.getFrame()); + if (result == null) { + return; + } + this.gui.getController().disconnectIfConnected(null); + try { + this.gui.getController().createClient(result.getUsername(), result.getIp(), result.getPort(), result.getPassword()); + } catch (IOException e) { + JOptionPane.showMessageDialog(this.gui.getFrame(), e.toString(), I18n.translate("menu.collab.connect.error"), JOptionPane.ERROR_MESSAGE); + this.gui.getController().disconnectIfConnected(null); + } + Arrays.fill(result.getPassword(), (char)0); + }); + this.connectToServerMenu = item; + } + { + JMenuItem item = new JMenuItem(I18n.translate("menu.collab.server.start")); + menu.add(item); + item.addActionListener(event -> { + if (this.gui.getController().getServer() != null) { + this.gui.getController().disconnectIfConnected(null); + return; + } + CreateServerDialog.Result result = CreateServerDialog.show(this.gui.getFrame()); + if (result == null) { + return; + } + this.gui.getController().disconnectIfConnected(null); + try { + this.gui.getController().createServer(result.getPort(), result.getPassword()); + } catch (IOException e) { + JOptionPane.showMessageDialog(this.gui.getFrame(), e.toString(), I18n.translate("menu.collab.server.start.error"), JOptionPane.ERROR_MESSAGE); + this.gui.getController().disconnectIfConnected(null); + } + }); + this.startServerMenu = item; + } + } /* * Help menu */ diff --git a/src/main/java/cuchaz/enigma/network/DedicatedEnigmaServer.java b/src/main/java/cuchaz/enigma/network/DedicatedEnigmaServer.java new file mode 100644 index 0000000..2cfe823 --- /dev/null +++ b/src/main/java/cuchaz/enigma/network/DedicatedEnigmaServer.java @@ -0,0 +1,164 @@ +package cuchaz.enigma.network; + +import com.google.common.io.MoreFiles; +import cuchaz.enigma.*; +import cuchaz.enigma.throwables.MappingParseException; +import cuchaz.enigma.translation.mapping.EntryRemapper; +import cuchaz.enigma.translation.mapping.serde.MappingFormat; +import cuchaz.enigma.utils.Utils; +import joptsimple.OptionParser; +import joptsimple.OptionSet; +import joptsimple.OptionSpec; + +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.TimeUnit; + +public class DedicatedEnigmaServer extends EnigmaServer { + + private final EnigmaProfile profile; + private final MappingFormat mappingFormat; + private final Path mappingsFile; + private final PrintWriter log; + private BlockingQueue tasks = new LinkedBlockingDeque<>(); + + public DedicatedEnigmaServer( + byte[] jarChecksum, + char[] password, + EnigmaProfile profile, + MappingFormat mappingFormat, + Path mappingsFile, + PrintWriter log, + EntryRemapper mappings, + int port + ) { + super(jarChecksum, password, mappings, port); + this.profile = profile; + this.mappingFormat = mappingFormat; + this.mappingsFile = mappingsFile; + this.log = log; + } + + @Override + protected void runOnThread(Runnable task) { + tasks.add(task); + } + + @Override + public void log(String message) { + super.log(message); + log.println(message); + } + + public static void main(String[] args) { + OptionParser parser = new OptionParser(); + + OptionSpec jarOpt = parser.accepts("jar", "Jar file to open at startup") + .withRequiredArg() + .required() + .withValuesConvertedBy(Main.PathConverter.INSTANCE); + + OptionSpec mappingsOpt = parser.accepts("mappings", "Mappings file to open at startup") + .withRequiredArg() + .required() + .withValuesConvertedBy(Main.PathConverter.INSTANCE); + + OptionSpec profileOpt = parser.accepts("profile", "Profile json to apply at startup") + .withRequiredArg() + .withValuesConvertedBy(Main.PathConverter.INSTANCE); + + OptionSpec portOpt = parser.accepts("port", "Port to run the server on") + .withOptionalArg() + .ofType(Integer.class) + .defaultsTo(EnigmaServer.DEFAULT_PORT); + + OptionSpec passwordOpt = parser.accepts("password", "The password to join the server") + .withRequiredArg() + .defaultsTo(""); + + OptionSpec logFileOpt = parser.accepts("log", "The log file to write to") + .withRequiredArg() + .withValuesConvertedBy(Main.PathConverter.INSTANCE) + .defaultsTo(Paths.get("log.txt")); + + OptionSet parsedArgs = parser.parse(args); + Path jar = parsedArgs.valueOf(jarOpt); + Path mappingsFile = parsedArgs.valueOf(mappingsOpt); + Path profileFile = parsedArgs.valueOf(profileOpt); + int port = parsedArgs.valueOf(portOpt); + char[] password = parsedArgs.valueOf(passwordOpt).toCharArray(); + if (password.length > EnigmaServer.MAX_PASSWORD_LENGTH) { + System.err.println("Password too long, must be at most " + EnigmaServer.MAX_PASSWORD_LENGTH + " characters"); + System.exit(1); + } + Path logFile = parsedArgs.valueOf(logFileOpt); + + System.out.println("Starting Enigma server"); + DedicatedEnigmaServer server; + try { + byte[] checksum = Utils.zipSha1(parsedArgs.valueOf(jarOpt)); + + EnigmaProfile profile = EnigmaProfile.read(profileFile); + Enigma enigma = Enigma.builder().setProfile(profile).build(); + System.out.println("Indexing Jar..."); + EnigmaProject project = enigma.openJar(jar, ProgressListener.none()); + + MappingFormat mappingFormat = MappingFormat.ENIGMA_DIRECTORY; + EntryRemapper mappings; + if (!Files.exists(mappingsFile)) { + mappings = EntryRemapper.empty(project.getJarIndex()); + } else { + System.out.println("Reading mappings..."); + if (Files.isDirectory(mappingsFile)) { + mappingFormat = MappingFormat.ENIGMA_DIRECTORY; + } else if ("zip".equalsIgnoreCase(MoreFiles.getFileExtension(mappingsFile))) { + mappingFormat = MappingFormat.ENIGMA_ZIP; + } else { + mappingFormat = MappingFormat.ENIGMA_FILE; + } + mappings = EntryRemapper.mapped(project.getJarIndex(), mappingFormat.read(mappingsFile, ProgressListener.none(), profile.getMappingSaveParameters())); + } + + PrintWriter log = new PrintWriter(Files.newBufferedWriter(logFile)); + + server = new DedicatedEnigmaServer(checksum, password, profile, mappingFormat, mappingsFile, log, mappings, port); + server.start(); + System.out.println("Server started"); + } catch (IOException | MappingParseException e) { + System.err.println("Error starting server!"); + e.printStackTrace(); + System.exit(1); + return; + } + + // noinspection RedundantSuppression + // noinspection Convert2MethodRef - javac 8 bug + Executors.newScheduledThreadPool(1).scheduleAtFixedRate(() -> server.runOnThread(() -> server.saveMappings()), 0, 1, TimeUnit.MINUTES); + Runtime.getRuntime().addShutdownHook(new Thread(server::saveMappings)); + + while (true) { + try { + server.tasks.take().run(); + } catch (InterruptedException e) { + break; + } + } + } + + @Override + public synchronized void stop() { + super.stop(); + System.exit(0); + } + + private void saveMappings() { + mappingFormat.write(getMappings().getObfToDeobf(), getMappings().takeMappingDelta(), mappingsFile, ProgressListener.none(), profile.getMappingSaveParameters()); + log.flush(); + } +} diff --git a/src/main/java/cuchaz/enigma/network/EnigmaClient.java b/src/main/java/cuchaz/enigma/network/EnigmaClient.java new file mode 100644 index 0000000..bfa53d7 --- /dev/null +++ b/src/main/java/cuchaz/enigma/network/EnigmaClient.java @@ -0,0 +1,85 @@ +package cuchaz.enigma.network; + +import cuchaz.enigma.gui.GuiController; +import cuchaz.enigma.network.packet.LoginC2SPacket; +import cuchaz.enigma.network.packet.Packet; +import cuchaz.enigma.network.packet.PacketRegistry; + +import javax.swing.SwingUtilities; +import java.io.DataInput; +import java.io.DataInputStream; +import java.io.DataOutput; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.net.Socket; +import java.net.SocketException; + +public class EnigmaClient { + + private final GuiController controller; + + private final String ip; + private final int port; + private Socket socket; + private DataOutput output; + + public EnigmaClient(GuiController controller, String ip, int port) { + this.controller = controller; + this.ip = ip; + this.port = port; + } + + public void connect() throws IOException { + socket = new Socket(ip, port); + output = new DataOutputStream(socket.getOutputStream()); + Thread thread = new Thread(() -> { + try { + DataInput input = new DataInputStream(socket.getInputStream()); + while (true) { + int packetId; + try { + packetId = input.readUnsignedByte(); + } catch (EOFException | SocketException e) { + break; + } + Packet packet = PacketRegistry.createS2CPacket(packetId); + if (packet == null) { + throw new IOException("Received invalid packet id " + packetId); + } + packet.read(input); + SwingUtilities.invokeLater(() -> packet.handle(controller)); + } + } catch (IOException e) { + controller.disconnectIfConnected(e.toString()); + return; + } + controller.disconnectIfConnected("Disconnected"); + }); + thread.setName("Client I/O thread"); + thread.setDaemon(true); + thread.start(); + } + + public synchronized void disconnect() { + if (socket != null && !socket.isClosed()) { + try { + socket.close(); + } catch (IOException e1) { + System.err.println("Failed to close socket"); + e1.printStackTrace(); + } + } + } + + + public void sendPacket(Packet packet) { + try { + output.writeByte(PacketRegistry.getC2SId(packet)); + packet.write(output); + } catch (IOException e) { + controller.disconnectIfConnected(e.toString()); + } + } + +} diff --git a/src/main/java/cuchaz/enigma/network/EnigmaServer.java b/src/main/java/cuchaz/enigma/network/EnigmaServer.java new file mode 100644 index 0000000..b0e15a3 --- /dev/null +++ b/src/main/java/cuchaz/enigma/network/EnigmaServer.java @@ -0,0 +1,292 @@ +package cuchaz.enigma.network; + +import cuchaz.enigma.gui.GuiController; +import cuchaz.enigma.network.packet.KickS2CPacket; +import cuchaz.enigma.network.packet.MessageS2CPacket; +import cuchaz.enigma.network.packet.Packet; +import cuchaz.enigma.network.packet.PacketRegistry; +import cuchaz.enigma.network.packet.RemoveMappingS2CPacket; +import cuchaz.enigma.network.packet.RenameS2CPacket; +import cuchaz.enigma.network.packet.UserListS2CPacket; +import cuchaz.enigma.translation.mapping.EntryMapping; +import cuchaz.enigma.translation.mapping.EntryRemapper; +import cuchaz.enigma.translation.representation.entry.Entry; +import cuchaz.enigma.utils.Message; + +import java.io.DataInput; +import java.io.DataInputStream; +import java.io.DataOutput; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; + +public abstract class EnigmaServer { + + // https://discordapp.com/channels/507304429255393322/566418023372816394/700292322918793347 + public static final int DEFAULT_PORT = 34712; + public static final int PROTOCOL_VERSION = 0; + public static final String OWNER_USERNAME = "Owner"; + public static final int CHECKSUM_SIZE = 20; + public static final int MAX_PASSWORD_LENGTH = 255; // length is written as a byte in the login packet + + private final int port; + private ServerSocket socket; + private List clients = new CopyOnWriteArrayList<>(); + private Map usernames = new HashMap<>(); + private Set unapprovedClients = new HashSet<>(); + + private final byte[] jarChecksum; + private final char[] password; + + public static final int DUMMY_SYNC_ID = 0; + private final EntryRemapper mappings; + private Map, Integer> syncIds = new HashMap<>(); + private Map> inverseSyncIds = new HashMap<>(); + private Map> clientsNeedingConfirmation = new HashMap<>(); + private int nextSyncId = DUMMY_SYNC_ID + 1; + + private static int nextIoId = 0; + + public EnigmaServer(byte[] jarChecksum, char[] password, EntryRemapper mappings, int port) { + this.jarChecksum = jarChecksum; + this.password = password; + this.mappings = mappings; + this.port = port; + } + + public void start() throws IOException { + socket = new ServerSocket(port); + log("Server started on " + socket.getInetAddress() + ":" + port); + Thread thread = new Thread(() -> { + try { + while (!socket.isClosed()) { + acceptClient(); + } + } catch (SocketException e) { + System.out.println("Server closed"); + } catch (IOException e) { + e.printStackTrace(); + } + }); + thread.setName("Server client listener"); + thread.setDaemon(true); + thread.start(); + } + + private void acceptClient() throws IOException { + Socket client = socket.accept(); + clients.add(client); + Thread thread = new Thread(() -> { + try { + DataInput input = new DataInputStream(client.getInputStream()); + while (true) { + int packetId; + try { + packetId = input.readUnsignedByte(); + } catch (EOFException | SocketException e) { + break; + } + Packet packet = PacketRegistry.createC2SPacket(packetId); + if (packet == null) { + throw new IOException("Received invalid packet id " + packetId); + } + packet.read(input); + runOnThread(() -> packet.handle(new ServerPacketHandler(client, this))); + } + } catch (IOException e) { + kick(client, e.toString()); + e.printStackTrace(); + return; + } + kick(client, "disconnect.disconnected"); + }); + thread.setName("Server I/O thread #" + (nextIoId++)); + thread.setDaemon(true); + thread.start(); + } + + public void stop() { + runOnThread(() -> { + if (socket != null && !socket.isClosed()) { + for (Socket client : clients) { + kick(client, "disconnect.server_closed"); + } + try { + socket.close(); + } catch (IOException e) { + System.err.println("Failed to close server socket"); + e.printStackTrace(); + } + } + }); + } + + public void kick(Socket client, String reason) { + if (!clients.remove(client)) return; + + sendPacket(client, new KickS2CPacket(reason)); + + clientsNeedingConfirmation.values().removeIf(list -> { + list.remove(client); + return list.isEmpty(); + }); + String username = usernames.remove(client); + try { + client.close(); + } catch (IOException e) { + System.err.println("Failed to close server client socket"); + e.printStackTrace(); + } + + if (username != null) { + System.out.println("Kicked " + username + " because " + reason); + sendMessage(Message.disconnect(username)); + } + sendUsernamePacket(); + } + + public boolean isUsernameTaken(String username) { + return usernames.containsValue(username); + } + + public void setUsername(Socket client, String username) { + usernames.put(client, username); + sendUsernamePacket(); + } + + private void sendUsernamePacket() { + List usernames = new ArrayList<>(this.usernames.values()); + Collections.sort(usernames); + sendToAll(new UserListS2CPacket(usernames)); + } + + public String getUsername(Socket client) { + return usernames.get(client); + } + + public void sendPacket(Socket client, Packet packet) { + if (!client.isClosed()) { + int packetId = PacketRegistry.getS2CId(packet); + try { + DataOutput output = new DataOutputStream(client.getOutputStream()); + output.writeByte(packetId); + packet.write(output); + } catch (IOException e) { + if (!(packet instanceof KickS2CPacket)) { + kick(client, e.toString()); + e.printStackTrace(); + } + } + } + } + + public void sendToAll(Packet packet) { + for (Socket client : clients) { + sendPacket(client, packet); + } + } + + public void sendToAllExcept(Socket excluded, Packet packet) { + for (Socket client : clients) { + if (client != excluded) { + sendPacket(client, packet); + } + } + } + + public boolean canModifyEntry(Socket client, Entry entry) { + if (unapprovedClients.contains(client)) { + return false; + } + + Integer syncId = syncIds.get(entry); + if (syncId == null) { + return true; + } + Set clients = clientsNeedingConfirmation.get(syncId); + return clients == null || !clients.contains(client); + } + + public int lockEntry(Socket exception, Entry entry) { + int syncId = nextSyncId; + nextSyncId++; + // sync id is sent as an unsigned short, can't have more than 65536 + if (nextSyncId == 65536) { + nextSyncId = DUMMY_SYNC_ID + 1; + } + Integer oldSyncId = syncIds.get(entry); + if (oldSyncId != null) { + clientsNeedingConfirmation.remove(oldSyncId); + } + syncIds.put(entry, syncId); + inverseSyncIds.put(syncId, entry); + Set clients = new HashSet<>(this.clients); + clients.remove(exception); + clientsNeedingConfirmation.put(syncId, clients); + return syncId; + } + + public void confirmChange(Socket client, int syncId) { + if (usernames.containsKey(client)) { + unapprovedClients.remove(client); + } + + Set clients = clientsNeedingConfirmation.get(syncId); + if (clients != null) { + clients.remove(client); + if (clients.isEmpty()) { + clientsNeedingConfirmation.remove(syncId); + syncIds.remove(inverseSyncIds.remove(syncId)); + } + } + } + + public void sendCorrectMapping(Socket client, Entry entry, boolean refreshClassTree) { + EntryMapping oldMapping = mappings.getDeobfMapping(entry); + String oldName = oldMapping == null ? null : oldMapping.getTargetName(); + if (oldName == null) { + sendPacket(client, new RemoveMappingS2CPacket(DUMMY_SYNC_ID, entry)); + } else { + sendPacket(client, new RenameS2CPacket(0, entry, oldName, refreshClassTree)); + } + } + + protected abstract void runOnThread(Runnable task); + + public void log(String message) { + System.out.println(message); + } + + protected boolean isRunning() { + return !socket.isClosed(); + } + + public byte[] getJarChecksum() { + return jarChecksum; + } + + public char[] getPassword() { + return password; + } + + public EntryRemapper getMappings() { + return mappings; + } + + public void sendMessage(Message message) { + log(String.format("[MSG] %s", message.translate())); + sendToAll(new MessageS2CPacket(message)); + } + +} diff --git a/src/main/java/cuchaz/enigma/network/IntegratedEnigmaServer.java b/src/main/java/cuchaz/enigma/network/IntegratedEnigmaServer.java new file mode 100644 index 0000000..21c6825 --- /dev/null +++ b/src/main/java/cuchaz/enigma/network/IntegratedEnigmaServer.java @@ -0,0 +1,16 @@ +package cuchaz.enigma.network; + +import cuchaz.enigma.translation.mapping.EntryRemapper; + +import javax.swing.*; + +public class IntegratedEnigmaServer extends EnigmaServer { + public IntegratedEnigmaServer(byte[] jarChecksum, char[] password, EntryRemapper mappings, int port) { + super(jarChecksum, password, mappings, port); + } + + @Override + protected void runOnThread(Runnable task) { + SwingUtilities.invokeLater(task); + } +} diff --git a/src/main/java/cuchaz/enigma/network/ServerPacketHandler.java b/src/main/java/cuchaz/enigma/network/ServerPacketHandler.java new file mode 100644 index 0000000..8618553 --- /dev/null +++ b/src/main/java/cuchaz/enigma/network/ServerPacketHandler.java @@ -0,0 +1,22 @@ +package cuchaz.enigma.network; + +import java.net.Socket; + +public class ServerPacketHandler { + + private final Socket client; + private final EnigmaServer server; + + public ServerPacketHandler(Socket client, EnigmaServer server) { + this.client = client; + this.server = server; + } + + public Socket getClient() { + return client; + } + + public EnigmaServer getServer() { + return server; + } +} diff --git a/src/main/java/cuchaz/enigma/network/packet/ChangeDocsC2SPacket.java b/src/main/java/cuchaz/enigma/network/packet/ChangeDocsC2SPacket.java new file mode 100644 index 0000000..4d5d86f --- /dev/null +++ b/src/main/java/cuchaz/enigma/network/packet/ChangeDocsC2SPacket.java @@ -0,0 +1,59 @@ +package cuchaz.enigma.network.packet; + +import cuchaz.enigma.network.EnigmaServer; +import cuchaz.enigma.network.ServerPacketHandler; +import cuchaz.enigma.translation.mapping.EntryMapping; +import cuchaz.enigma.translation.representation.entry.Entry; +import cuchaz.enigma.utils.Message; +import cuchaz.enigma.utils.Utils; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; + +public class ChangeDocsC2SPacket implements Packet { + private Entry entry; + private String newDocs; + + ChangeDocsC2SPacket() { + } + + public ChangeDocsC2SPacket(Entry entry, String newDocs) { + this.entry = entry; + this.newDocs = newDocs; + } + + @Override + public void read(DataInput input) throws IOException { + this.entry = PacketHelper.readEntry(input); + this.newDocs = PacketHelper.readString(input); + } + + @Override + public void write(DataOutput output) throws IOException { + PacketHelper.writeEntry(output, entry); + PacketHelper.writeString(output, newDocs); + } + + @Override + public void handle(ServerPacketHandler handler) { + EntryMapping mapping = handler.getServer().getMappings().getDeobfMapping(entry); + + boolean valid = handler.getServer().canModifyEntry(handler.getClient(), entry); + if (!valid) { + String oldDocs = mapping == null ? null : mapping.getJavadoc(); + handler.getServer().sendPacket(handler.getClient(), new ChangeDocsS2CPacket(EnigmaServer.DUMMY_SYNC_ID, entry, oldDocs == null ? "" : oldDocs)); + return; + } + + if (mapping == null) { + mapping = new EntryMapping(handler.getServer().getMappings().deobfuscate(entry).getName()); + } + handler.getServer().getMappings().mapFromObf(entry, mapping.withDocs(Utils.isBlank(newDocs) ? null : newDocs)); + + int syncId = handler.getServer().lockEntry(handler.getClient(), entry); + handler.getServer().sendToAllExcept(handler.getClient(), new ChangeDocsS2CPacket(syncId, entry, newDocs)); + handler.getServer().sendMessage(Message.editDocs(handler.getServer().getUsername(handler.getClient()), entry)); + } + +} diff --git a/src/main/java/cuchaz/enigma/network/packet/ChangeDocsS2CPacket.java b/src/main/java/cuchaz/enigma/network/packet/ChangeDocsS2CPacket.java new file mode 100644 index 0000000..bf5b7cb --- /dev/null +++ b/src/main/java/cuchaz/enigma/network/packet/ChangeDocsS2CPacket.java @@ -0,0 +1,44 @@ +package cuchaz.enigma.network.packet; + +import cuchaz.enigma.analysis.EntryReference; +import cuchaz.enigma.gui.GuiController; +import cuchaz.enigma.translation.representation.entry.Entry; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; + +public class ChangeDocsS2CPacket implements Packet { + private int syncId; + private Entry entry; + private String newDocs; + + ChangeDocsS2CPacket() { + } + + public ChangeDocsS2CPacket(int syncId, Entry entry, String newDocs) { + this.syncId = syncId; + this.entry = entry; + this.newDocs = newDocs; + } + + @Override + public void read(DataInput input) throws IOException { + this.syncId = input.readUnsignedShort(); + this.entry = PacketHelper.readEntry(input); + this.newDocs = PacketHelper.readString(input); + } + + @Override + public void write(DataOutput output) throws IOException { + output.writeShort(syncId); + PacketHelper.writeEntry(output, entry); + PacketHelper.writeString(output, newDocs); + } + + @Override + public void handle(GuiController controller) { + controller.changeDocs(new EntryReference<>(entry, entry.getName()), newDocs, false); + controller.sendPacket(new ConfirmChangeC2SPacket(syncId)); + } +} diff --git a/src/main/java/cuchaz/enigma/network/packet/ConfirmChangeC2SPacket.java b/src/main/java/cuchaz/enigma/network/packet/ConfirmChangeC2SPacket.java new file mode 100644 index 0000000..78ef964 --- /dev/null +++ b/src/main/java/cuchaz/enigma/network/packet/ConfirmChangeC2SPacket.java @@ -0,0 +1,33 @@ +package cuchaz.enigma.network.packet; + +import cuchaz.enigma.network.ServerPacketHandler; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; + +public class ConfirmChangeC2SPacket implements Packet { + private int syncId; + + ConfirmChangeC2SPacket() { + } + + public ConfirmChangeC2SPacket(int syncId) { + this.syncId = syncId; + } + + @Override + public void read(DataInput input) throws IOException { + this.syncId = input.readUnsignedShort(); + } + + @Override + public void write(DataOutput output) throws IOException { + output.writeShort(syncId); + } + + @Override + public void handle(ServerPacketHandler handler) { + handler.getServer().confirmChange(handler.getClient(), syncId); + } +} diff --git a/src/main/java/cuchaz/enigma/network/packet/KickS2CPacket.java b/src/main/java/cuchaz/enigma/network/packet/KickS2CPacket.java new file mode 100644 index 0000000..bd007d3 --- /dev/null +++ b/src/main/java/cuchaz/enigma/network/packet/KickS2CPacket.java @@ -0,0 +1,33 @@ +package cuchaz.enigma.network.packet; + +import cuchaz.enigma.gui.GuiController; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; + +public class KickS2CPacket implements Packet { + private String reason; + + KickS2CPacket() { + } + + public KickS2CPacket(String reason) { + this.reason = reason; + } + + @Override + public void read(DataInput input) throws IOException { + this.reason = PacketHelper.readString(input); + } + + @Override + public void write(DataOutput output) throws IOException { + PacketHelper.writeString(output, reason); + } + + @Override + public void handle(GuiController controller) { + controller.disconnectIfConnected(reason); + } +} diff --git a/src/main/java/cuchaz/enigma/network/packet/LoginC2SPacket.java b/src/main/java/cuchaz/enigma/network/packet/LoginC2SPacket.java new file mode 100644 index 0000000..722cbbf --- /dev/null +++ b/src/main/java/cuchaz/enigma/network/packet/LoginC2SPacket.java @@ -0,0 +1,75 @@ +package cuchaz.enigma.network.packet; + +import cuchaz.enigma.network.EnigmaServer; +import cuchaz.enigma.network.ServerPacketHandler; +import cuchaz.enigma.utils.Message; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; +import java.util.Arrays; + +public class LoginC2SPacket implements Packet { + private byte[] jarChecksum; + private char[] password; + private String username; + + LoginC2SPacket() { + } + + public LoginC2SPacket(byte[] jarChecksum, char[] password, String username) { + this.jarChecksum = jarChecksum; + this.password = password; + this.username = username; + } + + @Override + public void read(DataInput input) throws IOException { + if (input.readUnsignedShort() != EnigmaServer.PROTOCOL_VERSION) { + throw new IOException("Mismatching protocol"); + } + this.jarChecksum = new byte[EnigmaServer.CHECKSUM_SIZE]; + input.readFully(jarChecksum); + this.password = new char[input.readUnsignedByte()]; + for (int i = 0; i < password.length; i++) { + password[i] = input.readChar(); + } + this.username = PacketHelper.readString(input); + } + + @Override + public void write(DataOutput output) throws IOException { + output.writeShort(EnigmaServer.PROTOCOL_VERSION); + output.write(jarChecksum); + output.writeByte(password.length); + for (char c : password) { + output.writeChar(c); + } + PacketHelper.writeString(output, username); + } + + @Override + public void handle(ServerPacketHandler handler) { + boolean usernameTaken = handler.getServer().isUsernameTaken(username); + handler.getServer().setUsername(handler.getClient(), username); + handler.getServer().log(username + " logged in with IP " + handler.getClient().getInetAddress().toString() + ":" + handler.getClient().getPort()); + + if (!Arrays.equals(password, handler.getServer().getPassword())) { + handler.getServer().kick(handler.getClient(), "disconnect.wrong_password"); + return; + } + + if (usernameTaken) { + handler.getServer().kick(handler.getClient(), "disconnect.username_taken"); + return; + } + + if (!Arrays.equals(jarChecksum, handler.getServer().getJarChecksum())) { + handler.getServer().kick(handler.getClient(), "disconnect.wrong_jar"); + return; + } + + handler.getServer().sendPacket(handler.getClient(), new SyncMappingsS2CPacket(handler.getServer().getMappings().getObfToDeobf())); + handler.getServer().sendMessage(Message.connect(username)); + } +} diff --git a/src/main/java/cuchaz/enigma/network/packet/MarkDeobfuscatedC2SPacket.java b/src/main/java/cuchaz/enigma/network/packet/MarkDeobfuscatedC2SPacket.java new file mode 100644 index 0000000..98d20d9 --- /dev/null +++ b/src/main/java/cuchaz/enigma/network/packet/MarkDeobfuscatedC2SPacket.java @@ -0,0 +1,48 @@ +package cuchaz.enigma.network.packet; + +import cuchaz.enigma.network.ServerPacketHandler; +import cuchaz.enigma.translation.mapping.EntryMapping; +import cuchaz.enigma.translation.representation.entry.Entry; +import cuchaz.enigma.utils.Message; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; + +public class MarkDeobfuscatedC2SPacket implements Packet { + private Entry entry; + + MarkDeobfuscatedC2SPacket() { + } + + public MarkDeobfuscatedC2SPacket(Entry entry) { + this.entry = entry; + } + + @Override + public void read(DataInput input) throws IOException { + this.entry = PacketHelper.readEntry(input); + } + + @Override + public void write(DataOutput output) throws IOException { + PacketHelper.writeEntry(output, entry); + } + + @Override + public void handle(ServerPacketHandler handler) { + boolean valid = handler.getServer().canModifyEntry(handler.getClient(), entry); + if (!valid) { + handler.getServer().sendCorrectMapping(handler.getClient(), entry, true); + return; + } + + handler.getServer().getMappings().mapFromObf(entry, new EntryMapping(handler.getServer().getMappings().deobfuscate(entry).getName())); + handler.getServer().log(handler.getServer().getUsername(handler.getClient()) + " marked " + entry + " as deobfuscated"); + + int syncId = handler.getServer().lockEntry(handler.getClient(), entry); + handler.getServer().sendToAllExcept(handler.getClient(), new MarkDeobfuscatedS2CPacket(syncId, entry)); + handler.getServer().sendMessage(Message.markDeobf(handler.getServer().getUsername(handler.getClient()), entry)); + + } +} diff --git a/src/main/java/cuchaz/enigma/network/packet/MarkDeobfuscatedS2CPacket.java b/src/main/java/cuchaz/enigma/network/packet/MarkDeobfuscatedS2CPacket.java new file mode 100644 index 0000000..b7d6eda --- /dev/null +++ b/src/main/java/cuchaz/enigma/network/packet/MarkDeobfuscatedS2CPacket.java @@ -0,0 +1,40 @@ +package cuchaz.enigma.network.packet; + +import cuchaz.enigma.analysis.EntryReference; +import cuchaz.enigma.gui.GuiController; +import cuchaz.enigma.translation.representation.entry.Entry; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; + +public class MarkDeobfuscatedS2CPacket implements Packet { + private int syncId; + private Entry entry; + + MarkDeobfuscatedS2CPacket() { + } + + public MarkDeobfuscatedS2CPacket(int syncId, Entry entry) { + this.syncId = syncId; + this.entry = entry; + } + + @Override + public void read(DataInput input) throws IOException { + this.syncId = input.readUnsignedShort(); + this.entry = PacketHelper.readEntry(input); + } + + @Override + public void write(DataOutput output) throws IOException { + output.writeShort(syncId); + PacketHelper.writeEntry(output, entry); + } + + @Override + public void handle(GuiController controller) { + controller.markAsDeobfuscated(new EntryReference<>(entry, entry.getName()), false); + controller.sendPacket(new ConfirmChangeC2SPacket(syncId)); + } +} diff --git a/src/main/java/cuchaz/enigma/network/packet/MessageC2SPacket.java b/src/main/java/cuchaz/enigma/network/packet/MessageC2SPacket.java new file mode 100644 index 0000000..b8e0f14 --- /dev/null +++ b/src/main/java/cuchaz/enigma/network/packet/MessageC2SPacket.java @@ -0,0 +1,39 @@ +package cuchaz.enigma.network.packet; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; + +import cuchaz.enigma.network.ServerPacketHandler; +import cuchaz.enigma.utils.Message; + +public class MessageC2SPacket implements Packet { + + private String message; + + MessageC2SPacket() { + } + + public MessageC2SPacket(String message) { + this.message = message; + } + + @Override + public void read(DataInput input) throws IOException { + message = PacketHelper.readString(input); + } + + @Override + public void write(DataOutput output) throws IOException { + PacketHelper.writeString(output, message); + } + + @Override + public void handle(ServerPacketHandler handler) { + String message = this.message.trim(); + if (!message.isEmpty()) { + handler.getServer().sendMessage(Message.chat(handler.getServer().getUsername(handler.getClient()), message)); + } + } + +} diff --git a/src/main/java/cuchaz/enigma/network/packet/MessageS2CPacket.java b/src/main/java/cuchaz/enigma/network/packet/MessageS2CPacket.java new file mode 100644 index 0000000..edeaae0 --- /dev/null +++ b/src/main/java/cuchaz/enigma/network/packet/MessageS2CPacket.java @@ -0,0 +1,36 @@ +package cuchaz.enigma.network.packet; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; + +import cuchaz.enigma.gui.GuiController; +import cuchaz.enigma.utils.Message; + +public class MessageS2CPacket implements Packet { + + private Message message; + + MessageS2CPacket() { + } + + public MessageS2CPacket(Message message) { + this.message = message; + } + + @Override + public void read(DataInput input) throws IOException { + message = Message.read(input); + } + + @Override + public void write(DataOutput output) throws IOException { + message.write(output); + } + + @Override + public void handle(GuiController handler) { + handler.addMessage(message); + } + +} diff --git a/src/main/java/cuchaz/enigma/network/packet/Packet.java b/src/main/java/cuchaz/enigma/network/packet/Packet.java new file mode 100644 index 0000000..2f16dfb --- /dev/null +++ b/src/main/java/cuchaz/enigma/network/packet/Packet.java @@ -0,0 +1,15 @@ +package cuchaz.enigma.network.packet; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; + +public interface Packet { + + void read(DataInput input) throws IOException; + + void write(DataOutput output) throws IOException; + + void handle(H handler); + +} diff --git a/src/main/java/cuchaz/enigma/network/packet/PacketHelper.java b/src/main/java/cuchaz/enigma/network/packet/PacketHelper.java new file mode 100644 index 0000000..464606e --- /dev/null +++ b/src/main/java/cuchaz/enigma/network/packet/PacketHelper.java @@ -0,0 +1,135 @@ +package cuchaz.enigma.network.packet; + +import cuchaz.enigma.translation.representation.MethodDescriptor; +import cuchaz.enigma.translation.representation.TypeDescriptor; +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.LocalVariableEntry; +import cuchaz.enigma.translation.representation.entry.MethodEntry; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +public class PacketHelper { + + private static final int ENTRY_CLASS = 0, ENTRY_FIELD = 1, ENTRY_METHOD = 2, ENTRY_LOCAL_VAR = 3; + private static final int MAX_STRING_LENGTH = 65535; + + public static Entry readEntry(DataInput input) throws IOException { + return readEntry(input, null, true); + } + + public static Entry readEntry(DataInput input, Entry parent, boolean includeParent) throws IOException { + int type = input.readUnsignedByte(); + + if (includeParent && input.readBoolean()) { + parent = readEntry(input, null, true); + } + + String name = readString(input); + + String javadocs = null; + if (input.readBoolean()) { + javadocs = readString(input); + } + + switch (type) { + case ENTRY_CLASS: { + if (parent != null && !(parent instanceof ClassEntry)) { + throw new IOException("Class requires class parent"); + } + return new ClassEntry((ClassEntry) parent, name, javadocs); + } + case ENTRY_FIELD: { + if (!(parent instanceof ClassEntry)) { + throw new IOException("Field requires class parent"); + } + TypeDescriptor desc = new TypeDescriptor(readString(input)); + return new FieldEntry((ClassEntry) parent, name, desc, javadocs); + } + case ENTRY_METHOD: { + if (!(parent instanceof ClassEntry)) { + throw new IOException("Method requires class parent"); + } + MethodDescriptor desc = new MethodDescriptor(readString(input)); + return new MethodEntry((ClassEntry) parent, name, desc, javadocs); + } + case ENTRY_LOCAL_VAR: { + if (!(parent instanceof MethodEntry)) { + throw new IOException("Local variable requires method parent"); + } + int index = input.readUnsignedShort(); + boolean parameter = input.readBoolean(); + return new LocalVariableEntry((MethodEntry) parent, index, name, parameter, javadocs); + } + default: throw new IOException("Received unknown entry type " + type); + } + } + + public static void writeEntry(DataOutput output, Entry entry) throws IOException { + writeEntry(output, entry, true); + } + + public static void writeEntry(DataOutput output, Entry entry, boolean includeParent) throws IOException { + // type + if (entry instanceof ClassEntry) { + output.writeByte(ENTRY_CLASS); + } else if (entry instanceof FieldEntry) { + output.writeByte(ENTRY_FIELD); + } else if (entry instanceof MethodEntry) { + output.writeByte(ENTRY_METHOD); + } else if (entry instanceof LocalVariableEntry) { + output.writeByte(ENTRY_LOCAL_VAR); + } else { + throw new IOException("Don't know how to serialize entry of type " + entry.getClass().getSimpleName()); + } + + // parent + if (includeParent) { + output.writeBoolean(entry.getParent() != null); + if (entry.getParent() != null) { + writeEntry(output, entry.getParent(), true); + } + } + + // name + writeString(output, entry.getName()); + + // javadocs + output.writeBoolean(entry.getJavadocs() != null); + if (entry.getJavadocs() != null) { + writeString(output, entry.getJavadocs()); + } + + // type-specific stuff + if (entry instanceof FieldEntry) { + writeString(output, ((FieldEntry) entry).getDesc().toString()); + } else if (entry instanceof MethodEntry) { + writeString(output, ((MethodEntry) entry).getDesc().toString()); + } else if (entry instanceof LocalVariableEntry) { + LocalVariableEntry localVar = (LocalVariableEntry) entry; + output.writeShort(localVar.getIndex()); + output.writeBoolean(localVar.isArgument()); + } + } + + public static String readString(DataInput input) throws IOException { + int length = input.readUnsignedShort(); + byte[] bytes = new byte[length]; + input.readFully(bytes); + return new String(bytes, StandardCharsets.UTF_8); + } + + public static void writeString(DataOutput output, String str) throws IOException { + byte[] bytes = str.getBytes(StandardCharsets.UTF_8); + if (bytes.length > MAX_STRING_LENGTH) { + throw new IOException("String too long, was " + bytes.length + " bytes, max " + MAX_STRING_LENGTH + " allowed"); + } + output.writeShort(bytes.length); + output.write(bytes); + } + +} diff --git a/src/main/java/cuchaz/enigma/network/packet/PacketRegistry.java b/src/main/java/cuchaz/enigma/network/packet/PacketRegistry.java new file mode 100644 index 0000000..ba5d9de --- /dev/null +++ b/src/main/java/cuchaz/enigma/network/packet/PacketRegistry.java @@ -0,0 +1,64 @@ +package cuchaz.enigma.network.packet; + +import cuchaz.enigma.gui.GuiController; +import cuchaz.enigma.network.ServerPacketHandler; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Supplier; + +public class PacketRegistry { + + private static final Map>, Integer> c2sPacketIds = new HashMap<>(); + private static final Map>> c2sPacketCreators = new HashMap<>(); + private static final Map>, Integer> s2cPacketIds = new HashMap<>(); + private static final Map>> s2cPacketCreators = new HashMap<>(); + + private static > void registerC2S(int id, Class clazz, Supplier creator) { + c2sPacketIds.put(clazz, id); + c2sPacketCreators.put(id, creator); + } + + private static > void registerS2C(int id, Class clazz, Supplier creator) { + s2cPacketIds.put(clazz, id); + s2cPacketCreators.put(id, creator); + } + + static { + registerC2S(0, LoginC2SPacket.class, LoginC2SPacket::new); + registerC2S(1, ConfirmChangeC2SPacket.class, ConfirmChangeC2SPacket::new); + registerC2S(2, RenameC2SPacket.class, RenameC2SPacket::new); + registerC2S(3, RemoveMappingC2SPacket.class, RemoveMappingC2SPacket::new); + registerC2S(4, ChangeDocsC2SPacket.class, ChangeDocsC2SPacket::new); + registerC2S(5, MarkDeobfuscatedC2SPacket.class, MarkDeobfuscatedC2SPacket::new); + registerC2S(6, MessageC2SPacket.class, MessageC2SPacket::new); + + registerS2C(0, KickS2CPacket.class, KickS2CPacket::new); + registerS2C(1, SyncMappingsS2CPacket.class, SyncMappingsS2CPacket::new); + registerS2C(2, RenameS2CPacket.class, RenameS2CPacket::new); + registerS2C(3, RemoveMappingS2CPacket.class, RemoveMappingS2CPacket::new); + registerS2C(4, ChangeDocsS2CPacket.class, ChangeDocsS2CPacket::new); + registerS2C(5, MarkDeobfuscatedS2CPacket.class, MarkDeobfuscatedS2CPacket::new); + registerS2C(6, MessageS2CPacket.class, MessageS2CPacket::new); + registerS2C(7, UserListS2CPacket.class, UserListS2CPacket::new); + } + + public static int getC2SId(Packet packet) { + return c2sPacketIds.get(packet.getClass()); + } + + public static Packet createC2SPacket(int id) { + Supplier> creator = c2sPacketCreators.get(id); + return creator == null ? null : creator.get(); + } + + public static int getS2CId(Packet packet) { + return s2cPacketIds.get(packet.getClass()); + } + + public static Packet createS2CPacket(int id) { + Supplier> creator = s2cPacketCreators.get(id); + return creator == null ? null : creator.get(); + } + +} diff --git a/src/main/java/cuchaz/enigma/network/packet/RemoveMappingC2SPacket.java b/src/main/java/cuchaz/enigma/network/packet/RemoveMappingC2SPacket.java new file mode 100644 index 0000000..a3f3d91 --- /dev/null +++ b/src/main/java/cuchaz/enigma/network/packet/RemoveMappingC2SPacket.java @@ -0,0 +1,55 @@ +package cuchaz.enigma.network.packet; + +import cuchaz.enigma.network.ServerPacketHandler; +import cuchaz.enigma.throwables.IllegalNameException; +import cuchaz.enigma.translation.representation.entry.Entry; +import cuchaz.enigma.utils.Message; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; + +public class RemoveMappingC2SPacket implements Packet { + private Entry entry; + + RemoveMappingC2SPacket() { + } + + public RemoveMappingC2SPacket(Entry entry) { + this.entry = entry; + } + + @Override + public void read(DataInput input) throws IOException { + this.entry = PacketHelper.readEntry(input); + } + + @Override + public void write(DataOutput output) throws IOException { + PacketHelper.writeEntry(output, entry); + } + + @Override + public void handle(ServerPacketHandler handler) { + boolean valid = handler.getServer().canModifyEntry(handler.getClient(), entry); + + if (valid) { + try { + handler.getServer().getMappings().removeByObf(entry); + } catch (IllegalNameException e) { + valid = false; + } + } + + if (!valid) { + handler.getServer().sendCorrectMapping(handler.getClient(), entry, true); + return; + } + + handler.getServer().log(handler.getServer().getUsername(handler.getClient()) + " removed the mapping for " + entry); + + int syncId = handler.getServer().lockEntry(handler.getClient(), entry); + handler.getServer().sendToAllExcept(handler.getClient(), new RemoveMappingS2CPacket(syncId, entry)); + handler.getServer().sendMessage(Message.removeMapping(handler.getServer().getUsername(handler.getClient()), entry)); + } +} diff --git a/src/main/java/cuchaz/enigma/network/packet/RemoveMappingS2CPacket.java b/src/main/java/cuchaz/enigma/network/packet/RemoveMappingS2CPacket.java new file mode 100644 index 0000000..7bb1b00 --- /dev/null +++ b/src/main/java/cuchaz/enigma/network/packet/RemoveMappingS2CPacket.java @@ -0,0 +1,40 @@ +package cuchaz.enigma.network.packet; + +import cuchaz.enigma.analysis.EntryReference; +import cuchaz.enigma.gui.GuiController; +import cuchaz.enigma.translation.representation.entry.Entry; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; + +public class RemoveMappingS2CPacket implements Packet { + private int syncId; + private Entry entry; + + RemoveMappingS2CPacket() { + } + + public RemoveMappingS2CPacket(int syncId, Entry entry) { + this.syncId = syncId; + this.entry = entry; + } + + @Override + public void read(DataInput input) throws IOException { + this.syncId = input.readUnsignedShort(); + this.entry = PacketHelper.readEntry(input); + } + + @Override + public void write(DataOutput output) throws IOException { + output.writeShort(syncId); + PacketHelper.writeEntry(output, entry); + } + + @Override + public void handle(GuiController controller) { + controller.removeMapping(new EntryReference<>(entry, entry.getName()), false); + controller.sendPacket(new ConfirmChangeC2SPacket(syncId)); + } +} diff --git a/src/main/java/cuchaz/enigma/network/packet/RenameC2SPacket.java b/src/main/java/cuchaz/enigma/network/packet/RenameC2SPacket.java new file mode 100644 index 0000000..03e95d6 --- /dev/null +++ b/src/main/java/cuchaz/enigma/network/packet/RenameC2SPacket.java @@ -0,0 +1,64 @@ +package cuchaz.enigma.network.packet; + +import cuchaz.enigma.network.ServerPacketHandler; +import cuchaz.enigma.throwables.IllegalNameException; +import cuchaz.enigma.translation.mapping.EntryMapping; +import cuchaz.enigma.translation.representation.entry.Entry; +import cuchaz.enigma.utils.Message; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; + +public class RenameC2SPacket implements Packet { + private Entry entry; + private String newName; + private boolean refreshClassTree; + + RenameC2SPacket() { + } + + public RenameC2SPacket(Entry entry, String newName, boolean refreshClassTree) { + this.entry = entry; + this.newName = newName; + this.refreshClassTree = refreshClassTree; + } + + @Override + public void read(DataInput input) throws IOException { + this.entry = PacketHelper.readEntry(input); + this.newName = PacketHelper.readString(input); + this.refreshClassTree = input.readBoolean(); + } + + @Override + public void write(DataOutput output) throws IOException { + PacketHelper.writeEntry(output, entry); + PacketHelper.writeString(output, newName); + output.writeBoolean(refreshClassTree); + } + + @Override + public void handle(ServerPacketHandler handler) { + boolean valid = handler.getServer().canModifyEntry(handler.getClient(), entry); + + if (valid) { + try { + handler.getServer().getMappings().mapFromObf(entry, new EntryMapping(newName)); + } catch (IllegalNameException e) { + valid = false; + } + } + + if (!valid) { + handler.getServer().sendCorrectMapping(handler.getClient(), entry, refreshClassTree); + return; + } + + handler.getServer().log(handler.getServer().getUsername(handler.getClient()) + " renamed " + entry + " to " + newName); + + int syncId = handler.getServer().lockEntry(handler.getClient(), entry); + handler.getServer().sendToAllExcept(handler.getClient(), new RenameS2CPacket(syncId, entry, newName, refreshClassTree)); + handler.getServer().sendMessage(Message.rename(handler.getServer().getUsername(handler.getClient()), entry, newName)); + } +} diff --git a/src/main/java/cuchaz/enigma/network/packet/RenameS2CPacket.java b/src/main/java/cuchaz/enigma/network/packet/RenameS2CPacket.java new file mode 100644 index 0000000..058f0e5 --- /dev/null +++ b/src/main/java/cuchaz/enigma/network/packet/RenameS2CPacket.java @@ -0,0 +1,48 @@ +package cuchaz.enigma.network.packet; + +import cuchaz.enigma.analysis.EntryReference; +import cuchaz.enigma.gui.GuiController; +import cuchaz.enigma.translation.representation.entry.Entry; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; + +public class RenameS2CPacket implements Packet { + private int syncId; + private Entry entry; + private String newName; + private boolean refreshClassTree; + + RenameS2CPacket() { + } + + public RenameS2CPacket(int syncId, Entry entry, String newName, boolean refreshClassTree) { + this.syncId = syncId; + this.entry = entry; + this.newName = newName; + this.refreshClassTree = refreshClassTree; + } + + @Override + public void read(DataInput input) throws IOException { + this.syncId = input.readUnsignedShort(); + this.entry = PacketHelper.readEntry(input); + this.newName = PacketHelper.readString(input); + this.refreshClassTree = input.readBoolean(); + } + + @Override + public void write(DataOutput output) throws IOException { + output.writeShort(syncId); + PacketHelper.writeEntry(output, entry); + PacketHelper.writeString(output, newName); + output.writeBoolean(refreshClassTree); + } + + @Override + public void handle(GuiController controller) { + controller.rename(new EntryReference<>(entry, entry.getName()), newName, refreshClassTree, false); + controller.sendPacket(new ConfirmChangeC2SPacket(syncId)); + } +} diff --git a/src/main/java/cuchaz/enigma/network/packet/SyncMappingsS2CPacket.java b/src/main/java/cuchaz/enigma/network/packet/SyncMappingsS2CPacket.java new file mode 100644 index 0000000..e6378d1 --- /dev/null +++ b/src/main/java/cuchaz/enigma/network/packet/SyncMappingsS2CPacket.java @@ -0,0 +1,88 @@ +package cuchaz.enigma.network.packet; + +import cuchaz.enigma.gui.GuiController; +import cuchaz.enigma.network.EnigmaServer; +import cuchaz.enigma.translation.mapping.EntryMapping; +import cuchaz.enigma.translation.mapping.tree.EntryTree; +import cuchaz.enigma.translation.mapping.tree.EntryTreeNode; +import cuchaz.enigma.translation.mapping.tree.HashEntryTree; +import cuchaz.enigma.translation.representation.entry.Entry; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +public class SyncMappingsS2CPacket implements Packet { + private EntryTree mappings; + + SyncMappingsS2CPacket() { + } + + public SyncMappingsS2CPacket(EntryTree mappings) { + this.mappings = mappings; + } + + @Override + public void read(DataInput input) throws IOException { + mappings = new HashEntryTree<>(); + int size = input.readInt(); + for (int i = 0; i < size; i++) { + readEntryTreeNode(input, null); + } + } + + private void readEntryTreeNode(DataInput input, Entry parent) throws IOException { + Entry entry = PacketHelper.readEntry(input, parent, false); + EntryMapping mapping = null; + if (input.readBoolean()) { + String name = input.readUTF(); + if (input.readBoolean()) { + String javadoc = input.readUTF(); + mapping = new EntryMapping(name, javadoc); + } else { + mapping = new EntryMapping(name); + } + } + mappings.insert(entry, mapping); + int size = input.readUnsignedShort(); + for (int i = 0; i < size; i++) { + readEntryTreeNode(input, entry); + } + } + + @Override + public void write(DataOutput output) throws IOException { + List> roots = mappings.getRootNodes().collect(Collectors.toList()); + output.writeInt(roots.size()); + for (EntryTreeNode node : roots) { + writeEntryTreeNode(output, node); + } + } + + private static void writeEntryTreeNode(DataOutput output, EntryTreeNode node) throws IOException { + PacketHelper.writeEntry(output, node.getEntry(), false); + EntryMapping value = node.getValue(); + output.writeBoolean(value != null); + if (value != null) { + PacketHelper.writeString(output, value.getTargetName()); + output.writeBoolean(value.getJavadoc() != null); + if (value.getJavadoc() != null) { + PacketHelper.writeString(output, value.getJavadoc()); + } + } + Collection> children = node.getChildNodes(); + output.writeShort(children.size()); + for (EntryTreeNode child : children) { + writeEntryTreeNode(output, child); + } + } + + @Override + public void handle(GuiController controller) { + controller.openMappings(mappings); + controller.sendPacket(new ConfirmChangeC2SPacket(EnigmaServer.DUMMY_SYNC_ID)); + } +} diff --git a/src/main/java/cuchaz/enigma/network/packet/UserListS2CPacket.java b/src/main/java/cuchaz/enigma/network/packet/UserListS2CPacket.java new file mode 100644 index 0000000..8904848 --- /dev/null +++ b/src/main/java/cuchaz/enigma/network/packet/UserListS2CPacket.java @@ -0,0 +1,44 @@ +package cuchaz.enigma.network.packet; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import cuchaz.enigma.gui.GuiController; + +public class UserListS2CPacket implements Packet { + + private List users; + + UserListS2CPacket() { + } + + public UserListS2CPacket(List users) { + this.users = users; + } + + @Override + public void read(DataInput input) throws IOException { + int len = input.readUnsignedShort(); + users = new ArrayList<>(len); + for (int i = 0; i < len; i++) { + users.add(input.readUTF()); + } + } + + @Override + public void write(DataOutput output) throws IOException { + output.writeShort(users.size()); + for (String user : users) { + PacketHelper.writeString(output, user); + } + } + + @Override + public void handle(GuiController handler) { + handler.updateUserList(users); + } + +} diff --git a/src/main/java/cuchaz/enigma/utils/Message.java b/src/main/java/cuchaz/enigma/utils/Message.java new file mode 100644 index 0000000..d7c5f23 --- /dev/null +++ b/src/main/java/cuchaz/enigma/utils/Message.java @@ -0,0 +1,392 @@ +package cuchaz.enigma.utils; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; +import java.util.Objects; + +import cuchaz.enigma.network.packet.PacketHelper; +import cuchaz.enigma.translation.representation.entry.Entry; + +public abstract class Message { + + public final String user; + + public static Chat chat(String user, String message) { + return new Chat(user, message); + } + + public static Connect connect(String user) { + return new Connect(user); + } + + public static Disconnect disconnect(String user) { + return new Disconnect(user); + } + + public static EditDocs editDocs(String user, Entry entry) { + return new EditDocs(user, entry); + } + + public static MarkDeobf markDeobf(String user, Entry entry) { + return new MarkDeobf(user, entry); + } + + public static RemoveMapping removeMapping(String user, Entry entry) { + return new RemoveMapping(user, entry); + } + + public static Rename rename(String user, Entry entry, String newName) { + return new Rename(user, entry, newName); + } + + public abstract String translate(); + + public abstract Type getType(); + + public static Message read(DataInput input) throws IOException { + byte typeId = input.readByte(); + if (typeId < 0 || typeId >= Type.values().length) { + throw new IOException(String.format("Invalid message type ID %d", typeId)); + } + Type type = Type.values()[typeId]; + String user = input.readUTF(); + switch (type) { + case CHAT: + String message = input.readUTF(); + return chat(user, message); + case CONNECT: + return connect(user); + case DISCONNECT: + return disconnect(user); + case EDIT_DOCS: + Entry entry = PacketHelper.readEntry(input); + return editDocs(user, entry); + case MARK_DEOBF: + entry = PacketHelper.readEntry(input); + return markDeobf(user, entry); + case REMOVE_MAPPING: + entry = PacketHelper.readEntry(input); + return removeMapping(user, entry); + case RENAME: + entry = PacketHelper.readEntry(input); + String newName = input.readUTF(); + return rename(user, entry, newName); + default: + throw new IllegalStateException("unreachable"); + } + } + + public void write(DataOutput output) throws IOException { + output.writeByte(getType().ordinal()); + PacketHelper.writeString(output, user); + } + + private Message(String user) { + this.user = user; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Message message = (Message) o; + return Objects.equals(user, message.user); + } + + @Override + public int hashCode() { + return Objects.hash(user); + } + + public enum Type { + CHAT, + CONNECT, + DISCONNECT, + EDIT_DOCS, + MARK_DEOBF, + REMOVE_MAPPING, + RENAME, + } + + public static final class Chat extends Message { + + public final String message; + + private Chat(String user, String message) { + super(user); + this.message = message; + } + + @Override + public void write(DataOutput output) throws IOException { + super.write(output); + PacketHelper.writeString(output, message); + } + + @Override + public String translate() { + return String.format(I18n.translate("message.chat.text"), user, message); + } + + @Override + public Type getType() { + return Type.CHAT; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + Chat chat = (Chat) o; + return Objects.equals(message, chat.message); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), message); + } + + @Override + public String toString() { + return String.format("Message.Chat { user: '%s', message: '%s' }", user, message); + } + + } + + public static final class Connect extends Message { + + private Connect(String user) { + super(user); + } + + @Override + public String translate() { + return String.format(I18n.translate("message.connect.text"), user); + } + + @Override + public Type getType() { + return Type.CONNECT; + } + + @Override + public String toString() { + return String.format("Message.Connect { user: '%s' }", user); + } + + } + + public static final class Disconnect extends Message { + + private Disconnect(String user) { + super(user); + } + + @Override + public String translate() { + return String.format(I18n.translate("message.disconnect.text"), user); + } + + @Override + public Type getType() { + return Type.DISCONNECT; + } + + @Override + public String toString() { + return String.format("Message.Disconnect { user: '%s' }", user); + } + + } + + public static final class EditDocs extends Message { + + public final Entry entry; + + private EditDocs(String user, Entry entry) { + super(user); + this.entry = entry; + } + + @Override + public void write(DataOutput output) throws IOException { + super.write(output); + PacketHelper.writeEntry(output, entry); + } + + @Override + public String translate() { + return String.format(I18n.translate("message.edit_docs.text"), user, entry); + } + + @Override + public Type getType() { + return Type.EDIT_DOCS; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + EditDocs editDocs = (EditDocs) o; + return Objects.equals(entry, editDocs.entry); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), entry); + } + + @Override + public String toString() { + return String.format("Message.EditDocs { user: '%s', entry: %s }", user, entry); + } + + } + + public static final class MarkDeobf extends Message { + + public final Entry entry; + + private MarkDeobf(String user, Entry entry) { + super(user); + this.entry = entry; + } + + @Override + public void write(DataOutput output) throws IOException { + super.write(output); + PacketHelper.writeEntry(output, entry); + } + + @Override + public String translate() { + return String.format(I18n.translate("message.mark_deobf.text"), user, entry); + } + + @Override + public Type getType() { + return Type.MARK_DEOBF; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + MarkDeobf markDeobf = (MarkDeobf) o; + return Objects.equals(entry, markDeobf.entry); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), entry); + } + + @Override + public String toString() { + return String.format("Message.MarkDeobf { user: '%s', entry: %s }", user, entry); + } + + } + + public static final class RemoveMapping extends Message { + + public final Entry entry; + + private RemoveMapping(String user, Entry entry) { + super(user); + this.entry = entry; + } + + @Override + public void write(DataOutput output) throws IOException { + super.write(output); + PacketHelper.writeEntry(output, entry); + } + + @Override + public String translate() { + return String.format(I18n.translate("message.remove_mapping.text"), user, entry); + } + + @Override + public Type getType() { + return Type.REMOVE_MAPPING; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + RemoveMapping that = (RemoveMapping) o; + return Objects.equals(entry, that.entry); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), entry); + } + + @Override + public String toString() { + return String.format("Message.RemoveMapping { user: '%s', entry: %s }", user, entry); + } + + } + + public static final class Rename extends Message { + + public final Entry entry; + public final String newName; + + private Rename(String user, Entry entry, String newName) { + super(user); + this.entry = entry; + this.newName = newName; + } + + @Override + public void write(DataOutput output) throws IOException { + super.write(output); + PacketHelper.writeEntry(output, entry); + PacketHelper.writeString(output, newName); + } + + @Override + public String translate() { + return String.format(I18n.translate("message.rename.text"), user, entry, newName); + } + + @Override + public Type getType() { + return Type.RENAME; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + Rename rename = (Rename) o; + return Objects.equals(entry, rename.entry) && + Objects.equals(newName, rename.newName); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), entry, newName); + } + + @Override + public String toString() { + return String.format("Message.Rename { user: '%s', entry: %s, newName: '%s' }", user, entry, newName); + } + + } + +} diff --git a/src/main/java/cuchaz/enigma/utils/Utils.java b/src/main/java/cuchaz/enigma/utils/Utils.java index b8f2ec2..b45b00d 100644 --- a/src/main/java/cuchaz/enigma/utils/Utils.java +++ b/src/main/java/cuchaz/enigma/utils/Utils.java @@ -15,6 +15,8 @@ import com.google.common.io.CharStreams; import org.objectweb.asm.Opcodes; import javax.swing.*; +import javax.swing.text.BadLocationException; +import javax.swing.text.JTextComponent; import java.awt.*; import java.awt.event.MouseEvent; import java.io.IOException; @@ -22,13 +24,16 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.net.URI; import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Comparator; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.*; import java.util.List; -import java.util.Locale; -import java.util.StringJoiner; import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; public class Utils { @@ -98,6 +103,19 @@ public class Utils { manager.setInitialDelay(oldDelay); } + public static Rectangle safeModelToView(JTextComponent component, int modelPos) { + if (modelPos < 0) { + modelPos = 0; + } else if (modelPos >= component.getText().length()) { + modelPos = component.getText().length(); + } + try { + return component.modelToView(modelPos); + } catch (BadLocationException e) { + throw new RuntimeException(e); + } + } + public static boolean getSystemPropertyAsBoolean(String property, boolean defValue) { String value = System.getProperty(property); return value == null ? defValue : Boolean.parseBoolean(value); @@ -111,6 +129,34 @@ public class Utils { } } + public static byte[] zipSha1(Path path) throws IOException { + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-1"); + } catch (NoSuchAlgorithmException e) { + // Algorithm guaranteed to be supported + throw new RuntimeException(e); + } + try (ZipFile zip = new ZipFile(path.toFile())) { + List entries = Collections.list(zip.entries()); + // only compare classes (some implementations may not generate directory entries) + entries.removeIf(entry -> !entry.getName().toLowerCase(Locale.ROOT).endsWith(".class")); + // different implementations may add zip entries in a different order + entries.sort(Comparator.comparing(ZipEntry::getName)); + byte[] buffer = new byte[8192]; + for (ZipEntry entry : entries) { + digest.update(entry.getName().getBytes(StandardCharsets.UTF_8)); + try (InputStream in = zip.getInputStream(entry)) { + int n; + while ((n = in.read(buffer)) != -1) { + digest.update(buffer, 0, n); + } + } + } + } + return digest.digest(); + } + public static String caplisiseCamelCase(String input){ StringJoiner stringJoiner = new StringJoiner(" "); for (String word : input.toLowerCase(Locale.ROOT).split("_")) { @@ -118,4 +164,16 @@ public class Utils { } return stringJoiner.toString(); } + + public static boolean isBlank(String input) { + if (input == null) { + return true; + } + for (int i = 0; i < input.length(); i++) { + if (!Character.isWhitespace(input.charAt(i))) { + return false; + } + } + return true; + } } diff --git a/src/main/resources/lang/en_us.json b/src/main/resources/lang/en_us.json index a8b3306..04f689c 100644 --- a/src/main/resources/lang/en_us.json +++ b/src/main/resources/lang/en_us.json @@ -41,6 +41,13 @@ "menu.view.scale": "Scale", "menu.view.scale.custom": "Custom...", "menu.view.search": "Search", + "menu.collab": "Collab", + "menu.collab.connect": "Connect to server", + "menu.collab.connect.error": "Error connecting to server", + "menu.collab.disconnect": "Disconnect", + "menu.collab.server.start": "Start server", + "menu.collab.server.start.error": "Error starting server", + "menu.collab.server.stop": "Stop server", "menu.help": "Help", "menu.help.about": "About", "menu.help.about.title": "%s - About", @@ -81,6 +88,9 @@ "info_panel.tree.implementations": "Implementations", "info_panel.tree.calls": "Call Graph", + "log_panel.messages": "Messages", + "log_panel.users": "Users", + "progress.operation": "%s - Operation in progress", "progress.jar.indexing": "Indexing jar", "progress.jar.indexing.entries": "Entries...", @@ -115,6 +125,34 @@ "prompt.close.cancel": "Cancel", "prompt.open": "Open", "prompt.cancel": "Cancel", + "prompt.connect.title": "Connect to server", + "prompt.connect.username": "Username", + "prompt.connect.ip": "IP", + "prompt.port": "Port", + "prompt.port.nan": "Port is not a number", + "prompt.port.invalid": "Port is out of range, should be between 0-65535", + "prompt.password": "Password", + "prompt.password.too_long": "Password is too long, it must be at most 255 characters.", + "prompt.create_server.title": "Create server", + + "disconnect.disconnected": "Disconnected", + "disconnect.server_closed": "Server closed", + "disconnect.wrong_jar": "Jar checksums don't match (you have the wrong jar)!", + "disconnect.wrong_password": "Incorrect password", + "disconnect.username_taken": "Username is taken", + + "message.chat.text": "%s: %s", + "message.connect.text": "[+] %s", + "message.disconnect.text": "[-] %s", + "message.edit_docs.text": "%s edited docs for %s", + "message.mark_deobf.text": "%s marked %s as deobfuscated", + "message.remove_mapping.text": "%s removed mappings for %s", + "message.rename.text": "%s renamed %s to %s", + + "status.disconnected": "Disconnected.", + "status.connected": "Connected.", + "status.connected_user_count": "Connected (%d users).", + "status.ready": "Ready.", "crash.title": "%s - Crash Report", "crash.summary": "%s has crashed! =(", diff --git a/src/main/resources/lang/fr_fr.json b/src/main/resources/lang/fr_fr.json index 12214cf..a1d55a2 100644 --- a/src/main/resources/lang/fr_fr.json +++ b/src/main/resources/lang/fr_fr.json @@ -39,6 +39,13 @@ "menu.view.languages.summary": "La nouvelle langue sera appliquée lors du prochain redémarrage.", "menu.view.languages.ok": "Ok", "menu.view.search": "Rechercher", + "menu.collab": "Collab", + "menu.collab.connect": "Se connecter à un serveur", + "menu.collab.connect.error": "Erreur lors de la connexion au serveur", + "menu.collab.disconnect": "Se déconnecter", + "menu.collab.server.start": "Démarrer le serveur", + "menu.collab.server.start.error": "Erreur lors du démarrage du serveur", + "menu.collab.server.stop": "Arrêter le serveur", "menu.help": "Aide", "menu.help.about": "À propos", "menu.help.about.title": "%s - À propos", @@ -79,6 +86,9 @@ "info_panel.tree.implementations": "Implémentations", "info_panel.tree.calls": "Graphique des appels", + "log_panel.messages": "Messages", + "log_panel.users": "Utilisateurs", + "progress.operation": "%s - Opération en cours", "progress.jar.indexing": "Indexation du jar", "progress.jar.indexing.entries": "Entrées...", @@ -111,6 +121,34 @@ "prompt.close.save": "Enregistrer et fermer", "prompt.close.discard": "Annuler les modifications", "prompt.close.cancel": "Annuler", + "prompt.connect.title": "Se connecter à un serveur", + "prompt.connect.username": "Nom d'utilisateur", + "prompt.connect.ip": "IP", + "prompt.port": "Port", + "prompt.port.nan": "Le port n'est pas un nombre", + "prompt.port.invalid": "Le port est hors de portée. Il doit être compris entre 0 et 65535.", + "prompt.password": "Mot de passe", + "prompt.password.too_long": "Le mot de passe est trop long. Il ne doit pas dépasser 255 caractères.", + "prompt.create_server.title": "Créer un serveur", + + "disconnect.disconnected": "Déconnecté", + "disconnect.server_closed": "Serveur fermé", + "disconnect.wrong_jar": "Les sommes de contrôle du jar ne correspondent pas (vous avez le mauvais jar) !", + "disconnect.wrong_password": "Mot de passe incorrect", + "disconnect.username_taken": "Le nom d'utilisateur est déjà pris", + + "message.chat.text": "%s : %s", + "message.connect.text": "[+] %s", + "message.disconnect.text": "[-] %s", + "message.edit_docs.text": "%s a édité les javadocs de %s", + "message.mark_deobf.text": "%s a marqué %s comme déobfusqué", + "message.remove_mapping.text": "%s a supprimé les mappings de %s", + "message.rename.text": "%s a renommé %s en %s", + + "status.disconnected": "Déconnecté.", + "status.connected": "Connecté.", + "status.connected_user_count": "Connecté (%d utilisateurs).", + "status.ready": "Prêt.", "crash.title": "%s - Rapport de plantage", "crash.summary": "%s a planté ! =(", -- cgit v1.2.3