From 706ba01939df7cf74aa6684fcda4f9222aabeeac Mon Sep 17 00:00:00 2001 From: Zefir Date: Sun, 11 Oct 2026 12:40:47 +0200 Subject: [PATCH 1/3] Fix windows jvm argument --- src/main/java/dev/aoqia/leaf/installer/Handler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/dev/aoqia/leaf/installer/Handler.java b/src/main/java/dev/aoqia/leaf/installer/Handler.java index c3c5bfd..88f0948 100755 --- a/src/main/java/dev/aoqia/leaf/installer/Handler.java +++ b/src/main/java/dev/aoqia/leaf/installer/Handler.java @@ -165,7 +165,7 @@ public JPanel makePanel(InstallerGui installerGui) { if (OperatingSystem.CURRENT == OperatingSystem.WINDOWS) { String tag = Main.LATEST_PROXY_NATIVE_TAG; - fmt += String.format("-agentlib:.leaf/lib/native-%s.dll", + fmt += String.format("-agentpath:.leaf/lib/native-%s.dll", tag.startsWith("v") ? tag.substring(1) : tag); fmt += " "; } From e52adb9875cba62db9417828c475b7508e70dcd5 Mon Sep 17 00:00:00 2001 From: Zefir Date: Sun, 11 Oct 2026 13:47:05 +0200 Subject: [PATCH 2/3] feat: Rework `Copy arg` button --- .../dev/aoqia/leaf/installer/Handler.java | 111 ++++++++++++++---- src/main/resources/lang/installer.properties | 3 +- 2 files changed, 88 insertions(+), 26 deletions(-) diff --git a/src/main/java/dev/aoqia/leaf/installer/Handler.java b/src/main/java/dev/aoqia/leaf/installer/Handler.java index 88f0948..a8f783e 100755 --- a/src/main/java/dev/aoqia/leaf/installer/Handler.java +++ b/src/main/java/dev/aoqia/leaf/installer/Handler.java @@ -25,7 +25,10 @@ import javax.swing.JLabel; import javax.swing.JOptionPane; import javax.swing.JPanel; +import javax.swing.JSeparator; +import javax.swing.JTextArea; import javax.swing.JTextField; +import javax.swing.SwingUtilities; import javax.swing.UIManager; import javax.swing.border.EmptyBorder; import javax.swing.filechooser.FileNameExtensionFilter; @@ -72,6 +75,9 @@ public abstract class Handler implements InstallerProgress { public JComboBox loaderVersionComboBox; public JCheckBox createConfigCheckbox; private JPanel pane; + private JTextArea launchArguments; + private String proxyJarName; + private LoaderVersion customLoaderVersion; protected static Component createSpacer() { return Box.createRigidArea(new Dimension(4, 0)); @@ -134,6 +140,7 @@ public JPanel makePanel(InstallerGui installerGui) { if (!selected && Main.LOADER_META.isComplete()) { updateLoaderVersions(Main.LOADER_META.getVersions()); } + updateLaunchArguments(); }); addRow(pane, c, "prompt.loader.version", loaderVersionComboBox, createSpacer(), loaderProxyCheckbox); @@ -156,32 +163,13 @@ public JPanel makePanel(InstallerGui installerGui) { addRow(pane, c, null, statusLabel = new JLabel()); statusLabel.setText(Utils.BUNDLE.getString("prompt.loading.versions")); + launchArguments = new JTextArea(); + launchArguments.setEditable(false); + copyArgButton = new JButton(Utils.BUNDLE.getString("prompt.copy.arg")); + copyArgButton.setEnabled(false); copyArgButton.addActionListener(e -> { - StringSelection s; - if (loaderProxyCheckbox.isSelected()) { - try { - String fmt = ""; - - if (OperatingSystem.CURRENT == OperatingSystem.WINDOWS) { - String tag = Main.LATEST_PROXY_NATIVE_TAG; - fmt += String.format("-agentpath:.leaf/lib/native-%s.dll", - tag.startsWith("v") ? tag.substring(1) : tag); - fmt += " "; - } - - fmt += String.format("-javaagent:.leaf/lib/%s", Utils.getLatestLoaderProxy().getJarName()); - - s = new StringSelection(fmt); - } catch (IOException exc) { - error(exc); - return; - } - } else { - s = new StringSelection(String.format("-pzexeconfig leaf-%s-%s.json", - queryLoaderVersion().name, gameVersionComboBox.getSelectedItem())); - } - + StringSelection s = new StringSelection(launchArguments.getText().replace('\n', ' ')); Toolkit.getDefaultToolkit().getSystemClipboard().setContents(s, null); }); @@ -191,13 +179,83 @@ public JPanel makePanel(InstallerGui installerGui) { install(loaderProxyCheckbox.isEnabled() && loaderProxyCheckbox.isSelected()); }); - addLastRow(pane, c, null, buttonInstall, copyArgButton); + addRow(pane, c, null, buttonInstall); + JSeparator separator = new JSeparator(); + c.gridwidth = 2; + c.fill = GridBagConstraints.HORIZONTAL; + pane.add(separator, c); + c.gridy++; + addRow(pane, c, null, new JLabel(Utils.BUNDLE.getString("prompt.launch.arguments"))); + JPanel argumentsPanel = new JPanel(); + argumentsPanel.setOpaque(false); + argumentsPanel.setBorder(UIManager.getBorder("TextField.border")); + argumentsPanel.add(launchArguments); + argumentsPanel.add(copyArgButton); + c.insets = new Insets(VERTICAL_SPACING, HORIZONTAL_SPACING, 2, HORIZONTAL_SPACING); + addLastRow(pane, c, null, argumentsPanel); + + gameVersionComboBox.addActionListener(e -> updateLaunchArguments()); + loaderVersionComboBox.addActionListener(e -> { + if (SELECT_CUSTOM_ITEM.equals(loaderVersionComboBox.getSelectedItem())) { + customLoaderVersion = null; + customLoaderVersion = queryLoaderVersion(); + } + updateLaunchArguments(); + }); + updateLaunchArguments(); + new Thread(() -> { + try { + String jarName; + synchronized (Utils.class) { + jarName = Utils.getLatestLoaderProxy().getJarName(); + } + SwingUtilities.invokeLater(() -> { + proxyJarName = jarName; + updateLaunchArguments(); + }); + } catch (IOException e) { + SwingUtilities.invokeLater(() -> error(e)); + } + }).start(); installerGui.updateSize(true); return pane; } + private void updateLaunchArguments() { + if (launchArguments == null) { + return; + } + + String arguments = ""; + if (loaderProxyCheckbox.isSelected()) { + if (proxyJarName != null) { + if (OperatingSystem.CURRENT == OperatingSystem.WINDOWS && Main.LATEST_PROXY_NATIVE_TAG != null) { + String tag = Main.LATEST_PROXY_NATIVE_TAG; + arguments = String.format("-agentpath:.leaf/lib/native-%s.dll\n", + tag.startsWith("v") ? tag.substring(1) : tag); + } + arguments += String.format("-javaagent:.leaf/lib/%s", proxyJarName); + arguments += " --"; + } + } else if (loaderVersionComboBox.getSelectedItem() != null && gameVersionComboBox.getSelectedItem() != null) { + String version = (String) loaderVersionComboBox.getSelectedItem(); + if (SELECT_CUSTOM_ITEM.equals(version)) { + version = customLoaderVersion == null ? null : customLoaderVersion.name; + } + if (version != null) { + arguments = String.format("-pzexeconfig leaf-%s-%s.json", + version, gameVersionComboBox.getSelectedItem()); + } + } + launchArguments.setText(arguments); + copyArgButton.setEnabled(true); + if (InstallerGui.instance != null) { + InstallerGui.instance.updateSize(false); + } + } + private void updateGameVersions(List versions) { gameVersionComboBox.removeAllItems(); @@ -234,6 +292,9 @@ protected LoaderVersion queryLoaderVersion() { if (!ret.equals(SELECT_CUSTOM_ITEM)) { return new LoaderVersion(ret); } else { + if (customLoaderVersion != null) { + return customLoaderVersion; + } // ask user for loader jar JFileChooser chooser = new JFileChooser(); diff --git a/src/main/resources/lang/installer.properties b/src/main/resources/lang/installer.properties index 614f4fc..cc37ca6 100755 --- a/src/main/resources/lang/installer.properties +++ b/src/main/resources/lang/installer.properties @@ -18,7 +18,8 @@ prompt.exception.occurrence=Exception occurred! prompt.exception.suppressed=Suppressed prompt.install=Install -prompt.copy.arg=Copy arg +prompt.copy.arg=Copy +prompt.launch.arguments=[!] Paste this to your game launch arguments after install: prompt.loader.version=Loader Version: prompt.loading.versions=Loading versions From 9c5b5a9cd88bb684437dd629160b098ac4b3af95 Mon Sep 17 00:00:00 2001 From: Zefir Date: Sun, 11 Oct 2026 15:05:56 +0200 Subject: [PATCH 3/3] feat: Add automatic launch options setup --- .../dev/aoqia/leaf/installer/Handler.java | 31 ++- .../leaf/installer/client/ClientHandler.java | 63 +++++- .../installer/client/ClientInstaller.java | 34 ++- .../installer/util/ClientLaunchOptions.java | 120 ++++++++++ .../installer/util/SteamLaunchOptions.java | 209 ++++++++++++++++++ src/main/resources/lang/installer.properties | 8 + 6 files changed, 446 insertions(+), 19 deletions(-) create mode 100644 src/main/java/dev/aoqia/leaf/installer/util/ClientLaunchOptions.java create mode 100644 src/main/java/dev/aoqia/leaf/installer/util/SteamLaunchOptions.java diff --git a/src/main/java/dev/aoqia/leaf/installer/Handler.java b/src/main/java/dev/aoqia/leaf/installer/Handler.java index a8f783e..59dd01b 100755 --- a/src/main/java/dev/aoqia/leaf/installer/Handler.java +++ b/src/main/java/dev/aoqia/leaf/installer/Handler.java @@ -98,6 +98,10 @@ public void setupPane1(JPanel pane, GridBagConstraints c, InstallerGui installer public void setupPane2(JPanel pane, GridBagConstraints c, InstallerGui installerGui) { } + protected Component makeLaunchOptionsPanel(JPanel manualPanel) { + return manualPanel; + } + public JPanel makePanel(InstallerGui installerGui) { pane = new JPanel(new GridBagLayout()); pane.setBorder(new EmptyBorder(4, 4, 4, 4)); @@ -160,8 +164,7 @@ public JPanel makePanel(InstallerGui installerGui) { createConfigCheckbox.setEnabled(false); addRow(pane, c, null, createConfigCheckbox); - addRow(pane, c, null, statusLabel = new JLabel()); - statusLabel.setText(Utils.BUNDLE.getString("prompt.loading.versions")); + statusLabel = new JLabel(Utils.BUNDLE.getString("prompt.loading.versions")); launchArguments = new JTextArea(); launchArguments.setEditable(false); @@ -179,20 +182,26 @@ public JPanel makePanel(InstallerGui installerGui) { install(loaderProxyCheckbox.isEnabled() && loaderProxyCheckbox.isSelected()); }); - addRow(pane, c, null, buttonInstall); - JSeparator separator = new JSeparator(); - c.gridwidth = 2; - c.fill = GridBagConstraints.HORIZONTAL; - pane.add(separator, c); - c.gridy++; - addRow(pane, c, null, new JLabel(Utils.BUNDLE.getString("prompt.launch.arguments"))); + JPanel manualPanel = new JPanel(new GridBagLayout()); + GridBagConstraints manualConstraints = new GridBagConstraints(); + manualConstraints.gridx = manualConstraints.gridy = 0; + manualConstraints.insets = c.insets; + addRow(manualPanel, manualConstraints, null, + new JLabel(Utils.BUNDLE.getString("prompt.launch.arguments"))); JPanel argumentsPanel = new JPanel(); argumentsPanel.setOpaque(false); argumentsPanel.setBorder(UIManager.getBorder("TextField.border")); argumentsPanel.add(launchArguments); argumentsPanel.add(copyArgButton); - c.insets = new Insets(VERTICAL_SPACING, HORIZONTAL_SPACING, 2, HORIZONTAL_SPACING); - addLastRow(pane, c, null, argumentsPanel); + addRow(manualPanel, manualConstraints, null, argumentsPanel); + addRow(pane, c, null, makeLaunchOptionsPanel(manualPanel)); + c.gridwidth = 2; + c.fill = GridBagConstraints.HORIZONTAL; + pane.add(new JSeparator(), c); + c.gridy++; + addRow(pane, c, null, statusLabel); + c.insets = new Insets(VERTICAL_SPACING, HORIZONTAL_SPACING, 10, HORIZONTAL_SPACING); + addLastRow(pane, c, null, buttonInstall); gameVersionComboBox.addActionListener(e -> updateLaunchArguments()); loaderVersionComboBox.addActionListener(e -> { diff --git a/src/main/java/dev/aoqia/leaf/installer/client/ClientHandler.java b/src/main/java/dev/aoqia/leaf/installer/client/ClientHandler.java index 371febe..b3b503f 100755 --- a/src/main/java/dev/aoqia/leaf/installer/client/ClientHandler.java +++ b/src/main/java/dev/aoqia/leaf/installer/client/ClientHandler.java @@ -16,14 +16,19 @@ package dev.aoqia.leaf.installer.client; import javax.swing.ImageIcon; +import javax.swing.JCheckBox; import javax.swing.JEditorPane; +import javax.swing.JLabel; import javax.swing.JOptionPane; import javax.swing.JPanel; +import javax.swing.JTabbedPane; import javax.swing.SwingUtilities; import javax.swing.event.HyperlinkEvent; import java.awt.Color; +import java.awt.Component; import java.awt.Desktop; import java.awt.GridBagConstraints; +import java.awt.GridLayout; import java.awt.Image; import java.awt.Toolkit; import java.io.FileNotFoundException; @@ -39,9 +44,35 @@ import dev.aoqia.leaf.installer.util.ArgumentParser; import dev.aoqia.leaf.installer.util.InstallerProgress; import dev.aoqia.leaf.installer.util.NoopCaret; +import dev.aoqia.leaf.installer.util.OperatingSystem; +import dev.aoqia.leaf.installer.util.SteamLaunchOptions; import dev.aoqia.leaf.installer.util.Utils; public class ClientHandler extends Handler { + private JTabbedPane launchOptionsTabs; + private JCheckBox steamLaunchOptionsCheckbox; + private JCheckBox alternateLaunchCheckbox; + + @Override + protected Component makeLaunchOptionsPanel(JPanel manualPanel) { + steamLaunchOptionsCheckbox = new JCheckBox(Utils.BUNDLE.getString("option.steam.launch.options"), true); + alternateLaunchCheckbox = new JCheckBox(Utils.BUNDLE.getString("option.alternate.launch"), true); + alternateLaunchCheckbox.setEnabled(OperatingSystem.CURRENT == OperatingSystem.WINDOWS); + JPanel automaticPanel = new JPanel(new GridLayout(0, 1)); + automaticPanel.add(steamLaunchOptionsCheckbox); + automaticPanel.add(alternateLaunchCheckbox); + JLabel closeSteamNotice = new JLabel(Utils.BUNDLE.getString("prompt.close.steam")); + automaticPanel.add(closeSteamNotice); + steamLaunchOptionsCheckbox.addItemListener(e -> { + closeSteamNotice.setVisible(steamLaunchOptionsCheckbox.isSelected()); + InstallerGui.instance.updateSize(false); + }); + launchOptionsTabs = new JTabbedPane(); + launchOptionsTabs.addTab(Utils.BUNDLE.getString("tab.automatic"), automaticPanel); + launchOptionsTabs.addTab(Utils.BUNDLE.getString("tab.manual"), manualPanel); + return launchOptionsTabs; + } + @Override public String name() { return Utils.BUNDLE.getString("tab.client"); @@ -95,7 +126,21 @@ public void setupPane2(JPanel pane, GridBagConstraints c, InstallerGui installer private void installInternal(boolean proxy) { String gameVersion = (String) gameVersionComboBox.getSelectedItem(); - boolean createConfig = createConfigCheckbox.isEnabled() && createConfigCheckbox.isSelected(); + boolean automatic = launchOptionsTabs.getSelectedIndex() == 0; + boolean steamLaunchOptions = automatic && steamLaunchOptionsCheckbox.isSelected(); + boolean alternateLaunch = automatic && alternateLaunchCheckbox.isEnabled() + && alternateLaunchCheckbox.isSelected(); + boolean createConfig = !proxy && (createConfigCheckbox.isSelected() || steamLaunchOptions); + String installDirectory = installLocation.getText(); + if (steamLaunchOptions) { + try { + SteamLaunchOptions.checkSteamClosed(); + } catch (IOException e) { + error(e); + buttonInstall.setEnabled(true); + return; + } + } final LoaderVersion loaderVersion; if (!proxy) { @@ -105,25 +150,31 @@ private void installInternal(boolean proxy) { loaderVersion = new LoaderVersion(Utils.getLatestLoaderProxy().version); } catch (IOException exc) { error(exc); + buttonInstall.setEnabled(true); return; } } + if (loaderVersion == null) { + buttonInstall.setEnabled(true); + return; + } System.out.println("Installing"); new Thread(() -> { try { - Path pzPath = Utils.normaliseClientGamePath(Paths.get(installLocation.getText())).toAbsolutePath(); - if (!Files.exists(pzPath)) { + Path gamePath = Utils.normaliseClientGamePath(Paths.get(installDirectory)).toAbsolutePath(); + if (!Files.exists(gamePath)) { throw new RuntimeException(Utils.BUNDLE.getString("progress.exception.no.launcher.directory")); } - new ClientInstaller(pzPath, gameVersion, loaderVersion, this).install(proxy, createConfig); + new ClientInstaller(gamePath, gameVersion, loaderVersion, this) + .install(proxy, createConfig, steamLaunchOptions, alternateLaunch); SwingUtilities.invokeLater(() -> showInstalledMessage(proxy, loaderVersion, gameVersion)); } catch (Exception e) { - error(e); + SwingUtilities.invokeLater(() -> error(e)); } finally { - buttonInstall.setEnabled(true); + SwingUtilities.invokeLater(() -> buttonInstall.setEnabled(true)); } }).start(); } diff --git a/src/main/java/dev/aoqia/leaf/installer/client/ClientInstaller.java b/src/main/java/dev/aoqia/leaf/installer/client/ClientInstaller.java index a1c14d0..a008cad 100755 --- a/src/main/java/dev/aoqia/leaf/installer/client/ClientInstaller.java +++ b/src/main/java/dev/aoqia/leaf/installer/client/ClientInstaller.java @@ -23,12 +23,14 @@ import org.apache.commons.collections4.iterators.IteratorChain; import dev.aoqia.leaf.installer.LoaderVersion; +import dev.aoqia.leaf.installer.util.ClientLaunchOptions; import dev.aoqia.leaf.installer.util.InstallerProgress; import dev.aoqia.leaf.installer.util.LaunchConfigUtil; import dev.aoqia.leaf.installer.util.LeafService; import dev.aoqia.leaf.installer.util.Library; import dev.aoqia.leaf.installer.util.OperatingSystem; import dev.aoqia.leaf.installer.util.Reference; +import dev.aoqia.leaf.installer.util.SteamLaunchOptions; import dev.aoqia.leaf.installer.util.Utils; import dev.aoqia.leaf.installer.util.json.LoaderJson; @@ -49,6 +51,15 @@ public ClientInstaller(Path gameDir, String gameVersion, LoaderVersion loaderVer } public void install(boolean proxy, boolean createConfig) throws IOException { + install(proxy, createConfig, false, false); + } + + public void install(boolean proxy, boolean createConfig, boolean steamLaunchOptions, boolean alternateLaunch) + throws IOException + { + if (steamLaunchOptions) { + SteamLaunchOptions.checkSteamClosed(); + } if (proxy) { System.out.printf("Installing leaf-loader-proxy for %s%n", gameVersion); } else { @@ -61,6 +72,7 @@ public void install(boolean proxy, boolean createConfig) throws IOException { var proxyLib = Utils.getLatestLoaderProxy(); String configName = String.format("leaf-%s-%s", proxy ? proxyLib.version : loaderVersion.name, gameVersion); + String mainClass = null; if (Files.exists(this.leafLibDir)) { Utils.deleteDirectory(this.leafLibDir); @@ -83,7 +95,7 @@ public void install(boolean proxy, boolean createConfig) throws IOException { LoaderJson loaderVersionJson = LeafService.queryMetaJson( "dist/loader/%s.json".formatted(loaderVersion.name), LoaderJson.class); LoaderJson.Libraries libsJson = loaderVersionJson.libraries(); - String mainClass = loaderVersionJson.mainClass().client(); + mainClass = loaderVersionJson.mainClass().client(); String mainClassInternal = mainClass.replace(".", "/"); // Putting loader dependency into the libs list for later download. @@ -109,12 +121,30 @@ public void install(boolean proxy, boolean createConfig) throws IOException { } }); - if (createConfig) { + if (createConfig || steamLaunchOptions) { var launchConfigUtil = new LaunchConfigUtil(gameDir); launchConfigUtil.createConfig(configName, mainClassInternal); } } + String agents = String.format("-javaagent:.leaf/lib/%s", proxyLib.getJarName()); + if (proxy && (steamLaunchOptions || alternateLaunch) && OperatingSystem.CURRENT == OperatingSystem.WINDOWS) { + try (var files = Files.list(leafLibDir)) { + String nativeName = files.filter(Files::isRegularFile).map(path -> path.getFileName().toString()) + .filter(name -> name.startsWith("native-") && name.endsWith(".dll")) + .findFirst().orElseThrow(() -> new IOException("No installed loader proxy native library found.")); + agents = "-agentpath:.leaf/lib/" + nativeName + " " + agents; + } + } + if (alternateLaunch) { + progress.updateProgress(Utils.BUNDLE.getString("progress.patch.alternate.launch")); + ClientLaunchOptions.patchAlternateLauncher(gameDir, proxy ? agents : "", mainClass); + } + if (steamLaunchOptions) { + progress.updateProgress(Utils.BUNDLE.getString("progress.patch.steam.launch.options")); + SteamLaunchOptions.update(gameDir, proxy ? agents + " --" : "-pzexeconfig " + configName + ".json"); + } + progress.updateProgress(Utils.BUNDLE.getString("progress.done")); } } diff --git a/src/main/java/dev/aoqia/leaf/installer/util/ClientLaunchOptions.java b/src/main/java/dev/aoqia/leaf/installer/util/ClientLaunchOptions.java new file mode 100644 index 0000000..72a3e0f --- /dev/null +++ b/src/main/java/dev/aoqia/leaf/installer/util/ClientLaunchOptions.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2016-2025 FabricMC, aoqia + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.aoqia.leaf.installer.util; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import dev.aoqia.leaf.installer.util.json.LauncherConfig; + +public final class ClientLaunchOptions { + private static final Pattern LEAF_ARGUMENTS = Pattern.compile( + "(?i)(? entries = new ArrayList<>(Arrays.asList(assignmentValue(classpath).split(";"))); + entries.removeIf(entry -> entry.trim().replace('\\', '/').matches("(?i)(?:\\./)?\\.leaf/lib(?:/.*)?")); + if (agents.isEmpty()) { + entries.add(".leaf/lib/*"); + } + content = classpath.replaceFirst(Matcher.quoteReplacement(assignment(classpath, "PZ_CLASSPATH", + String.join(";", entries)))); + Matcher launch = Pattern.compile("(?i)(-cp\\s+(?:\"[^\"]+\"|\\S+)\\s+)\\S+").matcher(content); + if (!launch.find()) { + throw new IOException("Could not find the Java command in the alternative launcher."); + } + return launch.replaceAll("$1" + Matcher.quoteReplacement(mainClass.replace('/', '.'))); + } + + private static Pattern assignmentPattern(String name) { + return Pattern.compile("(?im)^([ \\t]*@?set[ \\t]+)(\"?)" + name + "=([^\\r\\n]*)$"); + } + + private static String assignmentValue(Matcher matcher) throws IOException { + String value = matcher.group(3).trim(); + if (!matcher.group(2).isEmpty()) { + if (!value.endsWith("\"")) { + throw new IOException("Unclosed quoted batch assignment."); + } + value = value.substring(0, value.length() - 1); + } + return value; + } + + private static String assignment(Matcher matcher, String name, String value) { + return matcher.group(1) + matcher.group(2) + name + "=" + value + matcher.group(2); + } + + static void writeWithBackup(Path path, String content) throws IOException { + if (Files.readString(path).equals(content)) { + return; + } + Path backup = path.resolveSibling(path.getFileName() + ".leaf-backup"); + if (Files.notExists(backup)) { + Files.copy(path, backup); + } + Files.writeString(path, content); + } +} diff --git a/src/main/java/dev/aoqia/leaf/installer/util/SteamLaunchOptions.java b/src/main/java/dev/aoqia/leaf/installer/util/SteamLaunchOptions.java new file mode 100644 index 0000000..7bfa516 --- /dev/null +++ b/src/main/java/dev/aoqia/leaf/installer/util/SteamLaunchOptions.java @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2016-2025 FabricMC, aoqia + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.aoqia.leaf.installer.util; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class SteamLaunchOptions { + private SteamLaunchOptions() {} + + public static void checkSteamClosed() throws IOException { + try (var processes = ProcessHandle.allProcesses()) { + if (processes.anyMatch(process -> process.info().command().map(command -> { + String name = Path.of(command).getFileName().toString(); + return name.equalsIgnoreCase("steam.exe") || name.equalsIgnoreCase("steam"); + }).orElse(false))) { + throw new IOException(Utils.BUNDLE.getString("progress.exception.steam.running")); + } + } + } + + public static void update(Path gameDir, String arguments) throws IOException { + checkSteamClosed(); + Path steamDir = findSteamDirectory(gameDir); + List configs; + try (var users = Files.list(steamDir.resolve("userdata"))) { + configs = users.filter(Files::isDirectory).map(user -> user.resolve("config/localconfig.vdf")) + .filter(Files::isRegularFile).toList(); + } + if (configs.isEmpty()) { + throw new IOException("No Steam user configurations found in " + steamDir); + } + List updates = new ArrayList<>(); + for (Path config : configs) { + updates.add(patchConfig(Files.readString(config), arguments)); + } + checkSteamClosed(); + for (int i = 0; i < configs.size(); i++) { + ClientLaunchOptions.writeWithBackup(configs.get(i), updates.get(i)); + } + } + + private static Path findSteamDirectory(Path gameDir) throws IOException { + List candidates = new ArrayList<>(); + if (OperatingSystem.CURRENT == OperatingSystem.WINDOWS) { + for (String key : List.of("HKCU\\Software\\Valve\\Steam", "HKLM\\Software\\Valve\\Steam", + "HKLM\\Software\\WOW6432Node\\Valve\\Steam")) { + Process query = new ProcessBuilder("reg", "query", key, "/v", "SteamPath").redirectErrorStream(true).start(); + String output = new String(query.getInputStream().readAllBytes()); + Matcher path = Pattern.compile("SteamPath\\s+REG_SZ\\s+([^\\r\\n]+)", Pattern.CASE_INSENSITIVE) + .matcher(output); + if (path.find()) { + candidates.add(Path.of(path.group(1).trim())); + break; + } + } + } + for (Path parent = gameDir.toAbsolutePath(); parent != null; parent = parent.getParent()) { + candidates.add(parent); + } + candidates.add(Utils.getDefaultSteamLibraryPath()); + candidates.add(Path.of(System.getProperty("user.home"), ".local/share/Steam")); + for (Path candidate : candidates) { + if (Files.isDirectory(candidate.resolve("userdata"))) { + return candidate; + } + } + throw new IOException("Could not locate Steam's userdata directory."); + } + + public static String patchConfig(String content, String arguments) throws IOException { + Node apps = new Parser(content).parse(); + for (String key : List.of("UserLocalConfigStore", "Software", "Valve", "Steam", "Apps")) { + apps = apps.child(key); + if (apps == null || apps.children == null) { + throw new IOException("Missing Steam configuration section: " + key); + } + } + String newline = content.contains("\r\n") ? "\r\n" : "\n"; + Node app = apps.child("108600"); + if (app == null) { + String entry = "\"108600\"" + newline + "\t\t\t\t\t{" + newline + + "\t\t\t\t\t\t\"LaunchOptions\"\t\t" + quote(arguments) + newline + "\t\t\t\t\t}"; + return insert(content, apps.end, entry, newline); + } + if (app.children == null) { + throw new IOException("Invalid Project Zomboid Steam configuration."); + } + Node options = app.child("LaunchOptions"); + if (options == null) { + return insert(content, app.end, "\"LaunchOptions\"\t\t" + quote(arguments), newline); + } + if (options.children != null) { + throw new IOException("Invalid Steam LaunchOptions value."); + } + String updated = ClientLaunchOptions.mergeArguments(options.value, arguments); + if (updated.equals(options.value)) { + return content; + } + return content.substring(0, options.start) + quote(updated) + content.substring(options.end); + } + + private static String insert(String content, int position, String entry, String newline) { + int lineStart = content.lastIndexOf('\n', position - 1) + 1; + String indent = content.substring(lineStart, position); + if (!indent.isBlank()) { + return content.substring(0, position) + newline + entry + newline + content.substring(position); + } + return content.substring(0, lineStart) + indent + "\t" + entry + newline + content.substring(lineStart); + } + + private static String quote(String value) { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + + private record Node(String key, String value, List children, int start, int end) { + Node child(String name) { + return children.stream().filter(node -> node.key.equalsIgnoreCase(name)).findFirst().orElse(null); + } + } + + private static final class Parser { + private final String content; + private final Matcher tokens; + private String token; + private int start; + private int end; + + Parser(String content) { + this.content = content; + tokens = Pattern.compile("\\G(?:[\\s\\uFEFF]++|//[^\\r\\n]*+)*+(\"(?:\\\\.|[^\"\\\\])*+\"|[{}]|[^\\s{}\"]++)") + .matcher(content); + } + + Node parse() throws IOException { + advance(); + Node root = new Node("", "", entries(false), 0, content.length()); + if (token != null) { + throw new IOException("Unexpected closing brace in Steam configuration."); + } + return root; + } + + private List entries(boolean nested) throws IOException { + List result = new ArrayList<>(); + while (token != null && !token.equals("}")) { + if (token.equals("{")) { + throw new IOException("Missing key in Steam configuration."); + } + String key = decode(token); + advance(); + if (token == null || token.equals("}")) { + throw new IOException("Missing value for Steam configuration key " + key); + } + int valueStart = start; + if (token.equals("{")) { + advance(); + List children = entries(true); + result.add(new Node(key, null, children, valueStart, start)); + } else { + result.add(new Node(key, decode(token), null, valueStart, end)); + } + advance(); + } + if (nested && token == null) { + throw new IOException("Unclosed Steam configuration section."); + } + return result; + } + + private void advance() throws IOException { + if (tokens.find()) { + token = tokens.group(1); + start = tokens.start(1); + end = tokens.end(1); + } else { + token = null; + if (!content.substring(end).replaceAll("//[^\\r\\n]*", "").isBlank()) { + throw new IOException("Invalid Steam configuration syntax."); + } + } + } + + private static String decode(String token) { + if (!token.startsWith("\"")) { + return token; + } + return token.substring(1, token.length() - 1).replace("\\\"", "\"").replace("\\\\", "\\"); + } + } +} diff --git a/src/main/resources/lang/installer.properties b/src/main/resources/lang/installer.properties index cc37ca6..6ccfecf 100755 --- a/src/main/resources/lang/installer.properties +++ b/src/main/resources/lang/installer.properties @@ -4,6 +4,8 @@ option.show.unstable=Show unstable option.use.proxy=Use proxy option.create.config=Create config option.create.script=Create script +option.steam.launch.options=Add to Steam launch options +option.alternate.launch=Patch alternative launch option progress.done=Done progress.done.server=Server successfully installed @@ -11,6 +13,9 @@ progress.download.library.entry=Downloading library {0} progress.exception.no.launcher.directory=No zomboid directory found! progress.exception.no.launcher.config=No ProjectZomboid64.json config found! progress.installing=Installing Leaf Loader {0} +progress.patch.steam.launch.options=Updating Steam launch options +progress.patch.alternate.launch=Patching alternative launcher +progress.exception.steam.running=Please close Steam completely and try again prompt.exception=Exception prompt.exception.caused.by=Caused by @@ -18,6 +23,7 @@ prompt.exception.occurrence=Exception occurred! prompt.exception.suppressed=Suppressed prompt.install=Install +prompt.close.steam=[!] Close Steam before installation. prompt.copy.arg=Copy prompt.launch.arguments=[!] Paste this to your game launch arguments after install: @@ -33,3 +39,5 @@ tooltip.use.proxy=Installs the latest version of the loader proxy instead of the tab.client=Client tab.server=Server +tab.automatic=Automatic +tab.manual=Manual