Skip to content

Commit

Permalink
Support jar mods
Browse files Browse the repository at this point in the history
  • Loading branch information
yihleego committed Feb 8, 2023
1 parent e13ba02 commit 627a17c
Show file tree
Hide file tree
Showing 5 changed files with 407 additions and 10 deletions.
219 changes: 219 additions & 0 deletions src/main/java/com/lucasallegri/bootstrap/ProjectXBootstrap.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
package com.lucasallegri.bootstrap;

import javax.swing.*;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Properties;
import java.util.Set;
import java.util.zip.ZipFile;

/**
* Bootstraps instead of {@literal com.threerings.projectx.client.ProjectXApp} for loading mods and language packs.
* <p>
* Makes sure the "META-INF/MANIFEST.MF" is included in each mod jars,
* and the main class must be specified.
*
* @author Leego Yih
*/
public class ProjectXBootstrap {
private static final String USER_DIR = System.getProperty("user.dir");
private static final String CODE_MODS_DIR = USER_DIR + "/code-mods/";
private static final String MANIFEST_PATH = "META-INF/MANIFEST.MF";
private static final String MAIN_CLASS_KEY = "Main-Class:";
private static final String NAME_KEY = "Name:";

public static void main(String[] args) throws Exception {
System.setProperty("com.threerings.io.enumPolicy", "ORDINAL");
// ak.gm()
if ((boolean) invokeMethod("com.samskivert.util.ak", "gm", null, new Object[0])) {
UIManager.setLookAndFeel(UIManager.getCrossPlatformLookAndFeelClassName());
}
// X.dM("projectx.log");
invokeMethod("com.threerings.util.X", "dM", null, new Object[]{"projectx.log"});

loadJarMods();

String ticket = null;
String password;
for (int i = 0; i < args.length; ++i) {
if ((password = args[i]).startsWith("+connect=")) {
ticket = password;
// com.samskivert.util.c.b(args, i, 1);
args = (String[]) invokeMethod("com.samskivert.util.c", "b", null, new Object[]{args, i, 1});
break;
}
}
String username = args.length > 0 ? args[0] : System.getProperty("username");
password = args.length > 1 ? args[1] : System.getProperty("password");
boolean encrypted = Boolean.getBoolean("encrypted");
String knight = args.length > 2 ? args[2] : System.getProperty("knight");
String action = args.length > 3 ? args[3] : System.getProperty("action");
String arg = args.length > 4 ? args[4] : System.getProperty("arg");
String sessionKey = System.getProperty("sessionKey");

Constructor<?> constructor = Class.forName("com.threerings.projectx.client.ProjectXApp")
.getDeclaredConstructor(String.class, String.class, boolean.class, String.class, String.class, String.class, String.class, String.class);
constructor.setAccessible(true);
Object app = constructor.newInstance(username, password, encrypted, knight, action, arg, sessionKey, ticket);
invokeMethod("com.threerings.projectx.client.ProjectXApp", "startup", app, new Object[0]);
}

static void loadJarMods() {
// Read disabled jar mods from KnightLauncher.properties
Set<String> disabledJarMods = new HashSet<>();
String disabledJarModsString = getConfigValue("modloader.disabledMods");
if (disabledJarModsString != null && disabledJarModsString.length() > 0) {
for (String disabledJarMod : disabledJarModsString.split(",")) {
disabledJarMod = disabledJarMod.trim();
if (disabledJarMod.length() > 0) {
disabledJarMods.add(disabledJarMod);
}
}
}
// Obtain the mod files in the "/code-mods/" directory
File codeModsDir = new File(CODE_MODS_DIR);
if (!codeModsDir.exists()) {
return;
}
File[] files = codeModsDir.listFiles();
if (files == null || files.length == 0) {
return;
}
List<File> jars = new ArrayList<File>(files.length);
for (File file : files) {
String filename = file.getName();
if (filename.endsWith(".jar")
&& !disabledJarMods.contains(filename)) {
jars.add(file);
}
}
if (jars.isEmpty()) {
return;
}
loadJars(jars);
loadClasses(jars);
}

static void loadJars(List<File> jars) {
// TODO Compatible with more versions of the JDK
Method method;
try {
method = URLClassLoader.class.getDeclaredMethod("addURL", URL.class);
} catch (Exception e) {
e.printStackTrace();
return;
}
URLClassLoader classLoader = (URLClassLoader) ClassLoader.getSystemClassLoader();
boolean accessible = method.isAccessible();
method.setAccessible(true);
for (File jar : jars) {
try {
method.invoke(classLoader, jar.toURI().toURL());
System.out.println("Loaded jar '" + jar.getName() + "'");
} catch (Exception e) {
System.out.println("Failed to load jar '" + jar.getName() + "'");
e.printStackTrace();
}
}
method.setAccessible(accessible);
}

static void loadClasses(List<File> jars) {
for (File jar : jars) {
String manifest = readZip(jar, MANIFEST_PATH);
if (manifest == null || manifest.length() == 0) {
System.out.println("Failed to read '" + MANIFEST_PATH + "' from '" + jar.getName() + "'");
continue;
}
String className = null;
String modName = null;
for (String item : manifest.split("\n")) {
if (item.startsWith(MAIN_CLASS_KEY)) {
className = item.replace(MAIN_CLASS_KEY, "").trim();
} else if (item.startsWith(NAME_KEY)) {
modName = item.replace(NAME_KEY, "").trim();
}
}
if (className == null || className.length() == 0) {
System.out.println("Failed to read 'Main-Class' from '" + jar.getName() + "'");
continue;
}
if (modName == null) {
modName = jar.getName();
}
System.out.println("Mod '" + modName + "' initializing");
try {
Class.forName(className);
System.out.println("Mod '" + modName + "' initialized");
} catch (Exception e) {
System.out.println("Failed to load mod '" + modName + "'");
e.printStackTrace();
}
}
}

static String readZip(File file, String entry) {
StringBuilder sb = new StringBuilder();
try {
ZipFile zip = new ZipFile(file);
InputStream is = zip.getInputStream(zip.getEntry(entry));
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
String s;
while ((s = reader.readLine()) != null) {
sb.append(s).append("\n");
}
reader.close();
zip.close();
return sb.toString();
} catch (Exception e) {
System.out.println("Failed to read '" + file.getName() + "'");
e.printStackTrace();
return null;
}
}

static String getConfigValue(String key) {
Properties _prop = new Properties();
String value;
try (InputStream is = Files.newInputStream(Paths.get(System.getProperty("user.dir") + File.separator + "KnightLauncher.properties"))) {
_prop.load(is);
value = _prop.getProperty(key);
return value;
} catch (IOException ignored) {
}
return null;
}

static Object invokeMethod(String className, String methodName, Object object, Object[] args) throws Exception {
Class<?> clazz = Class.forName(className);
Method[] methods = clazz.getDeclaredMethods();
for (int i = 0; i < methods.length; i++) {
Method method = methods[i];
if (method.getName().equals(methodName)) {
method.setAccessible(true);
return method.invoke(object, args);
}
}
methods = clazz.getMethods();
for (int i = 0; i < methods.length; i++) {
Method method = methods[i];
if (method.getName().equals(methodName)) {
method.setAccessible(true);
return method.invoke(object, args);
}
}
throw new NoSuchMethodException(methodName);
}
}
1 change: 1 addition & 0 deletions src/main/java/com/lucasallegri/launcher/LauncherApp.java
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ public LauncherApp () {
DiscordRPC.getInstance().start();
KeyboardController.start();
checkDirectories();
LauncherDigester.doDigest();
if (SystemUtil.isWindows()) checkShortcut();
}

Expand Down
160 changes: 160 additions & 0 deletions src/main/java/com/lucasallegri/launcher/LauncherDigester.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package com.lucasallegri.launcher;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.security.MessageDigest;

/**
* @author Leego Yih
*/
public class LauncherDigester {
public static final String KL_JAR_PATH = "/KnightLauncher.jar";
public static final String KL_JARV_PATH = "/KnightLauncher.jarv";
public static final String GETDOWN_PATH = "/getdown.txt";
public static final String DIGEST_PATH = "/digest.txt";
public static final String MAGIC_HEAD = "# Customized by KnightLauncher";
public static final String GETDOWN_PROJECTXAPP_CLASS = "class = com.threerings.projectx.client.ProjectXApp";
public static final String GETDOWN_PROJECTXAPP_CLIENT_CLASS = "client.class = com.threerings.projectx.client.ProjectXApp";
public static final String GETDOWN_BOOTSTRAP_CLASS = "class = com.lucasallegri.bootstrap.ProjectXBootstrap";
public static final String GETDOWN_BOOTSTRAP_CLIENT_CLASS = "client.class = com.lucasallegri.bootstrap.ProjectXBootstrap";
public static final String GETDOWN_KL_JAR = "code = KnightLauncher.jar";

public static void doDigest() {
try {
// Guarantee that the files exists
File klJarFile = new File(LauncherGlobals.USER_DIR + KL_JAR_PATH);
File klJarvFile = new File(LauncherGlobals.USER_DIR + KL_JARV_PATH);
File getdownFile = new File(LauncherGlobals.USER_DIR + GETDOWN_PATH);
File digestFile = new File(LauncherGlobals.USER_DIR + DIGEST_PATH);
String getdownContent = readFile(getdownFile).trim();
String digestContent = readFile(digestFile).trim();
// Build a new "getdown.txt" file if it has not been modified by KL
if (!getdownContent.startsWith(MAGIC_HEAD)) {
getdownFile.renameTo(new File(getdownFile.getAbsoluteFile() + ".bak"));
getdownContent = getdownContent
.replace("\n"+GETDOWN_PROJECTXAPP_CLIENT_CLASS, "\n#" + GETDOWN_PROJECTXAPP_CLIENT_CLASS)
.replace("\n"+GETDOWN_PROJECTXAPP_CLASS, "\n#" + GETDOWN_PROJECTXAPP_CLASS);
StringBuilder sb = new StringBuilder()
.append(MAGIC_HEAD).append("\n")
.append(getdownContent).append("\n\n")
.append("# KnightLauncher resources").append("\n")
.append(GETDOWN_KL_JAR).append("\n")
.append(GETDOWN_BOOTSTRAP_CLASS).append("\n")
.append(GETDOWN_BOOTSTRAP_CLIENT_CLASS).append("\n");
writeFile(getdownFile, sb.toString());
}
// For Windows
if (!klJarvFile.exists()) {
klJarvFile.createNewFile();
}
// Calculate the MD5 of the files
String klMD5 = md5(klJarFile.getAbsolutePath());
String getdownMD5 = md5(getdownFile.getAbsolutePath());
// Build a new "digest.txt" file from the original one
digestContent = digestContent.trim();
digestContent = digestContent.replaceFirst("digest\\.txt = \\S+", "");
digestContent = digestContent.replaceFirst("getdown\\.txt = \\S+\n", "getdown.txt = " + getdownMD5 + "\n");
if (digestContent.indexOf("KnightLauncher.jar") > 0) {
digestContent = digestContent.replaceFirst("KnightLauncher\\.jar = \\S+\n", "KnightLauncher.jar = " + klMD5 + "\n");
} else {
digestContent = digestContent + "KnightLauncher.jar = " + klMD5 + "\n";
}
// Append the final MD5 to the end
String digestMD5 = md5(digestContent.getBytes("UTF-8"));
digestContent = digestContent + "digest.txt = " + digestMD5 + "\n";
writeFile(digestFile, digestContent);
Log.log.info(String.format("\nKnightLauncher.jar: %s\ngetdown.txt: %s\ndigest.txt: %s\n", klMD5, getdownMD5, digestMD5));
} catch (Exception e) {
throw new RuntimeException(e);
}
}

static String readFile(File file) throws IOException {
StringBuilder sb = new StringBuilder();
BufferedReader reader = new BufferedReader(new FileReader(file));
String s;
while ((s = reader.readLine()) != null) {
sb.append(s).append("\n");
}
reader.close();
return sb.toString();
}

static void writeFile(File file, String s) throws IOException {
FileWriter writer = new FileWriter(file);
writer.write(s);
writer.flush();
writer.close();
}

static String md5(String path) throws Exception {
File file = new File(path);
byte[] data = new byte[(int) file.length()];
FileInputStream fis = new FileInputStream(file);
fis.read(data);
fis.close();
return md5(data);
}

static String md5(byte[] data) throws Exception {
byte[] hash = MessageDigest.getInstance("MD5").digest(data);
return encodeHex(hash);
}

/** Table for byte to hex string translation. */
static final char[] HEX = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};
/** Table for HEX to DEC byte translation. */
static final int[] DEC = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -1, -1, -1, -1, -1, -1, -1, 10, 11, 12, 13, 14, 15, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 10, 11, 12, 13, 14, 15};

static int getDec(int index) {
// Fast for correct values, slower for incorrect ones
try {
return DEC[index - 48];
} catch (ArrayIndexOutOfBoundsException e) {
return -1;
}
}

static byte getHex(int index) {
return (byte) HEX[index];
}

static String encodeHex(byte[] bytes) {
if (null == bytes) {
return null;
}
int i = 0;
char[] chars = new char[bytes.length << 1];
for (byte b : bytes) {
chars[i++] = HEX[(b & 0xf0) >> 4];
chars[i++] = HEX[b & 0x0f];
}
return new String(chars);
}

static byte[] decodeHex(String input) {
if (input == null) {
return null;
}
if ((input.length() & 1) == 1) {
// Odd number of characters
throw new IllegalArgumentException("Odd digits");
}
char[] inputChars = input.toCharArray();
byte[] result = new byte[input.length() >> 1];
for (int i = 0; i < result.length; i++) {
int upperNibble = getDec(inputChars[2 * i]);
int lowerNibble = getDec(inputChars[2 * i + 1]);
if (upperNibble < 0 || lowerNibble < 0) {
// Non hex character
throw new IllegalArgumentException("Non hex");
}
result[i] = (byte) ((upperNibble << 4) + lowerNibble);
}
return result;
}
}
Loading

0 comments on commit 627a17c

Please sign in to comment.