master 430ed22eaf76 cached
11 files
59.3 KB
14.4k tokens
33 symbols
1 requests
Download .txt
Repository: cdbbnnyCode/modpack-installer
Branch: master
Commit: 430ed22eaf76
Files: 11
Total size: 59.3 KB

Directory structure:
gitextract_epl3pjam/

├── .gitignore
├── ForgeHack.java
├── LICENSE
├── README.md
├── clean.py
├── fabric_install.py
├── forge_install.py
├── install.py
├── migrate.py
├── mod_download.py
└── util.py

================================================
FILE CONTENTS
================================================

================================================
FILE: .gitignore
================================================
# Python
__pycache__/

# Java
*.class

# NodeJS
node_modules/

# Atom
.ropeproject/

# Misc. log files
*.log

# Modpack Installer files
.modcache/
.packs/
global/
packs/

# Modpack zip files
*.zip
*.jar

# Launcher files
GPUCache/
user-preferences.json


================================================
FILE: ForgeHack.java
================================================
// Minecraft Forge Installer Hack
// Bypasses the GUI and installs the Forge client to the specified location
import java.io.File;
import java.net.URL;
import java.net.URLClassLoader;
import java.net.MalformedURLException;
import java.lang.reflect.Proxy;
import java.lang.reflect.Method;
import java.lang.reflect.InvocationHandler;
import java.io.OutputStream;
import java.util.function.Predicate;

public class ForgeHack
{
  private static ClassLoader getClassLoader(String jarfile)
  {
    try
    {
      URL url = new File(jarfile).toURI().toURL();
      return new URLClassLoader(new URL[] {url}, ForgeHack.class.getClassLoader());
    }
    catch (MalformedURLException e)
    {
      return null;
    }
  }

  private static class MInvocationHandler implements InvocationHandler
  {
    public Object invoke(Object proxy, Method method, Object[] args)
    {
      return true; // So stupid, but necessary
    }
  }

  /*
    Interfaces for each major release version:
    1.0 / 1.1 / 1.2 / 1.3:
      cpw.mods.fml.installer.ClientInstall().run(File path);
    1.5+:
      net.minecraftforge.installer.ClientInstall().run(
        File path, 
        com.google.common.base.Predicate<String> optionals
      );
    2.0:
      net.minecraftforge.installer.actions.ClientInstall(
        net.minecraftforge.installer.json.InstallV1 profile
          = net.minecraftforge.installer.json.Util.loadInstallProfile(),
        
        net.minecraftforge.installer.actions.ProgressCallback callback
      ).run(
        File path,
        java.util.function.Predicate<String> optionals,
        File installer = installer jar path
      )
  */

  private static boolean run_v1(File target, ClassLoader loader)
      throws ReflectiveOperationException
  {
    Class<?> clientInstall = null;
    try
    {
      clientInstall = Class.forName("cpw.mods.fml.installer.ClientInstall", true, loader);
    }
    catch (ClassNotFoundException e)
    {
      // must be v1.5+ or v2
      return false;
    }
    Object installer = clientInstall.getConstructor().newInstance();
    clientInstall.getDeclaredMethod("run", File.class).invoke(installer, target);

    return true;
  }

  private static boolean run_v15(File target, ClassLoader loader)
      throws ReflectiveOperationException
  {
    // "Import" the required classes
    Class<?> predicate = null;
    Class<?> clientInstall = null;

    try
    {
      predicate = Class.forName("com.google.common.base.Predicate", true, loader);
      clientInstall = Class.forName("net.minecraftforge.installer.ClientInstall", true, loader);
    }
    catch (ClassNotFoundException e)
    {
      return false;
    }

    // Make a Predicate that returns true, installing all optionals
    // This will install Mercurius unconditionally, but we might change that. TODO
    InvocationHandler handler = new ForgeHack.MInvocationHandler();
    Object pred = Proxy.newProxyInstance(loader, new Class[] {predicate}, handler);

    // Run the client install function. This will pop up a dialog and install Forge to the
    // specified Minecraft directory.
    Object install = clientInstall.getConstructor().newInstance();
    clientInstall.getDeclaredMethod("run", File.class, predicate).invoke(install, target, pred);

    return true;
  }

  private static boolean run_v2(File target, File jarfile, ClassLoader loader)
      throws ReflectiveOperationException
  {
    // classes needed
    Class<?> c_ClientInstall = null;
    Class<?> c_Util = null;
    Class<?> c_InstallV1 = null;
    Class<?> c_ProgressCallback = null;

    try
    {
      c_ClientInstall    = Class.forName("net.minecraftforge.installer.actions.ClientInstall", true, loader);
      c_Util             = Class.forName("net.minecraftforge.installer.json.Util", true, loader);
      try
      {
        c_InstallV1      = Class.forName("net.minecraftforge.installer.json.InstallV1", true, loader);
      }
      catch (ClassNotFoundException ex)
      {
        System.out.println("using v0 Install class");
        c_InstallV1      = Class.forName("net.minecraftforge.installer.json.Install", true, loader);
      }

      c_ProgressCallback = Class.forName("net.minecraftforge.installer.actions.ProgressCallback", true, loader);
    }
    catch (ClassNotFoundException e)
    {
      System.out.println(e.getMessage());
      return false;
    }

    // load the profile
    Object profile = c_Util.getDeclaredMethod("loadInstallProfile").invoke(null); // returns InstallV1 object

    

    // load the progress monitor (stdout only)
    Object monitor = c_ProgressCallback.getDeclaredMethod("withOutputs",
        OutputStream[].class).invoke(null, new Object[] { new OutputStream[] {System.out} });

    // create the ClientInstall
    Object installer = c_ClientInstall.getConstructor(c_InstallV1, c_ProgressCallback)
                      .newInstance(profile, monitor);

    // create the predicate
    Predicate<String> p = (param) -> true;

    // run the installer
    try
    {
      c_ClientInstall.getDeclaredMethod("run", File.class, Predicate.class, File.class)
        .invoke(installer, target, p, jarfile);
    }
    catch (NoSuchMethodException e)
    {
      c_ClientInstall.getDeclaredMethod("run", File.class, Predicate.class)
        .invoke(installer, target, p);
    }

    return true;
  }

  public static void main(String[] args) throws Exception
  {
    if (args.length < 2)
    {
      System.out.println("Usage: ForgeHack <jarfile> <target dir>");
      System.exit(1);
    }

    // NOTE: While this now has compatibility for all known versions of the
    // installer, older installers seem to be broken due to missing mirror servers.
    // As a result, support for any installer versions older than v2 may be
    // dropped in the near future.

    ClassLoader loader = getClassLoader(args[0]);
    File target = new File(args[1]);
    File jarfile = new File(args[0]);

    System.out.println("Attempting to launch v1.5+ installer");
    if (run_v15(target, loader))
    {
      System.out.println("Success!");
      return;
    }

    System.out.println("Attempting to launch v2 installer");
    if (run_v2(target, jarfile, loader))
    {
      System.out.println("Success!");
      return;
    }

    System.out.println("Attempting to launch v1 installer");
    if (run_v1(target, loader))
    {
      System.out.println("Success!");
      return;
    }

    System.out.println("Failed to launch installer.");
    System.exit(1);
  }
}


================================================
FILE: LICENSE
================================================
The MIT License

Copyright 2020-2023 Aidan Yaklin

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
## Modpack Installer  
###### V2.3.5

This command-line tool allows easy installation of CurseForge modpacks on Linux
systems. It installs each modpack in a semi-isolated environment, which prevents
them from modifying important settings and data in your main Minecraft installation.

This is a small project and may be unstable. If you find a bug, please
help me out by posting an [issue](https://github.com/cdbbnnyCode/modpack-installer/issues)!

**V2.3 update info**: Now uses the *official* CurseForge API. This has some major impacts:
* API requests are now authenticated with a key, and are now rate-limited on the client side
  to avoid excessive requests with this project's key.
  * **NOTE TO DEVELOPERS** - Forks and modifications of this project *must* use a new API key.
    See [here](https://support.curseforge.com/en/support/solutions/articles/9000208346-about-the-curseforge-api-and-how-to-apply-for-a-key) for details.
* Some mods now disallow 3rd-party distribution. These mods will be listed in the installer's output
  and must be downloaded manually from the CurseForge website. (Download URLs are provided directly).
  While this is tedious, it allows mod creators to always receive ad revenue from the download page.

**V2.2 update info**: After updating to version 2.2, please run the `migrate.py`
script to create launcher profiles for your modpacks in your main `.minecraft`
directory. See the changelog below for details.

Minecraft Forge auto-installation should now work with all current versions of the installer.
If it does not work properly, please post an issue reporting the error as well as the version
of the installer.

**V2.1 update info**: After updating to version 2.1, please run the `clean.py` script
to upgrade all of your existing modpacks.

### Features
* Simple command-line interface
* Supports Forge and Fabric modpacks
* Caches and re-uses mods across packs to save on bandwidth and drive usage
* Modpacks can be launched directly from the official launcher; no third-party authentication required
* Supports installing to the Minecraft app from Flatpak
  * Uses 'sandbox mode' to ensure that the mods are placed inside the Flatpak sandbox environment
    where the game can still access them

### Requirements  
This program requires the Minecraft launcher, Python 3, and a JDK (8 or
higher). The only dependency library that is not automatically installed is Requests,
which can be installed with pip (or your favorite method of installing Python
libraries):  
```
pip3 install --user requests
```

### How to Use
* Download a modpack and move the zip file into this directory.
* Open a terminal in this directory and type:
  ```
  python install.py <modpack_name.zip>
  ```
  replacing `<modpack_name.zip>` with the name of the zip file you just downloaded.
  * If the installer fails to install the modloader automatically,
    delete the modpack directory out of `packs/`
    and run the program with the `--manual` flag:
    ```
    python install.py --manual <modpack_name.zip>
    ```
    This will open the modloader's install GUI. Point it to your **main**
    `.minecraft` directory (should be default) and click 'Install Client'.
* To launch the modpack, simply load the Minecraft launcher normally. The modpack
  will appear as a new installation under the 'Installations' drop-down menu.
* To uninstall a modpack, simply delete its folder under the `packs/` directory
  and remove the installation from the Minecraft launcher. All of your saves, 
  resource packs, and shader packs will be retained and available in your other 
  modpacks.
  * Note that deleting the modpack does not automatically delete any mod files, as
    they are stored in a central `.modcache` directory. To clean up unused mods, run
    the `clean.py` script.
* You can use the `-b` flag in order to automatically open any modpacks
  that need to be installed manually. This will open them in your default
  browser using `webbrowser`.
* Use `python install.py -h` for a complete list of available commands

### How it Works
The installer script goes through several steps to install the modpack:
* First, it unzips the provided zip file into the `.packs` folder. The zip file
  contains a manifest file defining which version of Forge to use and a list of
  all of the mods in the pack, along with resource and configuration files.
* Next, it creates a `.minecraft` directory for the modpack, which is used to store
  the modpack data.
* Next, it runs [`forge_install.py`](/forge_install.py) to install Forge. This script downloads the
  requested version of the Forge installer and uses the [`ForgeHack.java`](/ForgeHack.java) program
  to bypass the install GUI and install directly to the user's *main* `.minecraft` folder.
  * The Fabric installer has command-line options to install the client directly, so `fabric_install.py`
    directly runs the installer.
* Next, it uses the [`mod_download.py`](/mod_download.py) script to download the required mods into
  the `.modcache` folder. The downloader script also generates a list of the mod
  jar files that are used by the modpack. The installer script then uses this
  list to create symbolic links to each mod. This reduces total disk usage when multiple
  modpacks use the same mod.
* Finally, the installer copies all of the folders in `overrides` from the unzipped
  modpack folder into the modpack's `.minecraft` folder.

#### The `clean.py` script
This script is intended to upgrade modpacks created with previous versions of the installer
as well as remove unused mods from the `.modcache` folder. Currently, it
* Deletes the `assets` folder from each existing modpack and links it into the `global`
  folder. This should improve download times when installing new modpacks as the assets
  (mainly language and sound files) do not need to be entirely re-downloaded for each install.
* Deletes any mods from the cache that aren't linked to by any modpacks.

#### The `migrate.py` script
This script creates launcher profiles for each existing installation in the user's *main* 
`.minecraft` directory. It also moves all Minecraft Forge/Fabric installations into the main
`.minecraft` directory. This allows all of the modpacks to be launched directly from the Minecraft 
launcher and eliminates issues related to launcher login and update files across multiple working 
directories.

### Limitations/Known Bugs
* This program only runs on Linux. (It might run on Mac, but I seriously doubt it.)
  As Windows/Mac users can use the official Curse client instead, these operating
  systems will not be supported by this tool.
* This tool always installs all mods, regardless of whether they are marked as
  required.
* The modpack's manifest format suggests that multiple mod loaders may be used
  in a single pack. I have not seen any modpacks that use this feature, so it
  is currently unsupported. If you do find a modpack that does this, please let
  me know by posting an issue.

### License
This project is licensed under the MIT license. See the LICENSE file for details.

### Disclaimer
This project is not endorsed by or affiliated with CurseForge, Overwolf, or Microsoft in any way.
All product and company names are the registered trademarks of their original owners.

### Changelog
#### v2.3.5 - 2024-04-15
* New feature - modpacks can be updated in-place using the `--update` option
  * This installs the new version and then uninstalls the previous version, but it copies any
    files (screenshots, options, etc.) from the previous version automatically.
* Several bug fixes 
  * The manual download tool looks for files with spaces
    ([#42](https://github.com/cdbbnnyCode/modpack-installer/issues/42)),
    ([#43](https://github.com/cdbbnnyCode/modpack-installer/issues/43))
  * Fix Forge download URL detection
    ([#40](https://github.com/cdbbnnyCode/modpack-installer/issues/40)),
    ([#41](https://github.com/cdbbnnyCode/modpack-installer/issues/41))
    * The previous implementation was matching incorrect URLs and having issues with 404 responses not having
      any Content-Length field.
  * Fix fallback distutils import for old Python versions (below 3.8)

#### v2.3.4 - 2023-05-21
* Fixes a bug preventing the game from accessing mod files when launched via the Flatpak app
  ([#31](https://github.com/cdbbnnyCode/modpack-installer/issues/31))
  * Flatpak has a sandbox that blocks access to the filesystem outside `~/.var/app/<appname>/`
    unless explicitly specified otherwise. By default, the modpack installer creates a
    complete game directory and stores mods relative to itself (in `packs/` and `.modcache`,
    respectively).
  * This update adds a 'sandbox' mode that automatically enables if Flatpak is being used and
    moves modpack files closer to the main `.minecraft` location so that they exist within the
    Flatpak sandbox.
* Uses `shutil` instead of the deprecated `distutils` to recursively copy directories
  * ...except that `shutil.copytree` in Python versions before 3.8 does not support copying over
    existing directories, so those older versions will still use `distutils`.

#### v2.3.3 - 2023-05-08
* New features from community pull requests:
  * New `--open-browser` option will automatically open all of the manual download links in the
    browser (not recommended if there are many mods that need to be downloaded manually, as all
    of the links will be opened simultaneously)
    ([#28](https://github.com/cdbbnnyCode/modpack-installer/pull/28)).
  * Support for changing the user's `.minecraft` directory
    ([#15](https://github.com/cdbbnnyCode/modpack-installer/pull/15)). Automatically checks in the
    default location (`$HOME/.minecraft`) as well as the flatpak install location. Other locations
    can be chosen with the `--mcdir` option or by editing the `user_preferences.json` file.
* Fixes syntax error in v2.3.2
  * Original fix was force-pushed over the same commit but did not apply to the existing tag (my bad)
* Manual download URLs now point to `legacy.curseforge.com` instead of `www.curseforge.com`
  * It seems like the data is being moved from the legacy site to the new site, and some files only
    exist on the new site and not the old one. If any manual download links return a 404 error,
    try changing the URL to start with `www.curseforge.com`.

#### v2.3.2 - 2023-02-24
* Fix crash in the datapack detection logic when the modpack data has already been successfully
  installed. ([#26](https://github.com/cdbbnnyCode/modpack-installer/issues/26))

#### v2.3.1 - 2023-02-07
* Detect included datapacks (i.e. for Repurposed Structures) and install them to
  `.minecraft/datapacks`. Some modpacks will find datapacks at this location and will automatically
  include them in new worlds, but this is not vanilla behavior (AFAIK).
* [Forge] Read the Minecraft Forge download page to determine the file name rather than assuming that
  it follows a consistent pattern ([#25](https://github.com/cdbbnnyCode/modpack-installer/pull/25)).

#### v2.3.0 - 2022-07-06
* Use the officially documented CurseForge API
  * Add a project-specific API key from CurseForge; derived projects must use a different key!
  * Add experimental rate-limiting (3 JSON requests per second)
  * Request the user to manually download files that have the Project Distribution Toggle disabled.
    The script will directly import these files from the user's download directory.
* Fix the `status_bar()` function so that the status bar is right-aligned properly

#### v2.2.1-beta - 2022-01-25
* Fix `ForgeHack` to work with older installer versions (tested on latest major releases down to
  1.7.10).
* Automatically recompile the `ForgeHack` class file when its corresponding source file is updated.
* Fix a serious mod downloader bug where server errors would cause only the retried downloads to be
  linked correctly (#12).

#### v2.2-beta - 2022-01-10
* Move modloaders and launcher profiles to the main `.minecraft` folder.
  * This approach works better with recent versions of the launcher because of the way that they
    handle accounts and automatic updates.
  * All modpack-related data (mods, saves, options, config, etc.) is still kept isolated. Only the 
    modloader (which appears as a separate Minecraft version) and the launcher profile are migrated.
  * The `migrate.py` script is provided to move existing installations.
* Update `ForgeHack` so that it works for recent versions of the Forge installer.
* Fix mod downloader so that it handles server errors properly (#9).

#### v2.1-beta - 2021-07-24
* Migrate `assets` to a global directory
* Add `clean.py` script to migrate the `assets` folder in existing modpacks and remove
  unused mods.

#### v2.0-beta - 2021-07-10
* Fabric modloader support (#1)
* Add `--manual` option to open the modloader installer GUI when automatic installation
  fails
* Generate a `launcher-profiles.json` file automatically instead of using the Minecraft
  launcher to generate it
* Clean up code

#### v1.1-beta - 2020-04-25
* Rewrite mod downloader in Python
* Extract resource packs (included in the manifest's mod list) into the resourcepacks
  directory
* Ensure that files and directories are both copied properly from the modpack's overrides

#### v1.0-beta - 2020-04-25
Initial version--uses NodeJS script to fetch mod files


================================================
FILE: clean.py
================================================
import os
import shutil
import pathlib
import json

from util import get_user_preference

def make_global(dir, gdir):
    if not os.path.exists(gdir):
        os.mkdir(gdir)
    
    if not os.path.islink(dir):
        print("Converting %s to a global directory" % dir)
        if os.path.isdir(dir):
            shutil.rmtree(dir) # safe for downloaded things, but not for user created content
        elif os.path.exists(dir):
            os.remove(dir)
        os.symlink(os.path.abspath(gdir), dir, True)
        return True
    return False

def main(override_mcdir=None, override_inst_root=None):
    # TODO add command line arguments to override minecraft/modpack directories
    print("Modpack Maintenance v1.1.0")

    mods = set()

    moved = 0
    deleted = 0

    sandbox = get_user_preference("sandbox")
    mcdir = get_user_preference("minecraft_dir")
    if sandbox:
        install_root = str(pathlib.Path(mcdir).parent) + '/modpack'
    else:
        install_root = '.'

    if override_mcdir is not None:
        mcdir = override_mcdir
    if override_inst_root is not None:
        install_root = override_inst_root
        
    print("Using user Minecraft path %s" % mcdir)
    print("Using modpack path %s" % install_root)

    for packdirn in os.listdir(install_root + '/packs'):
        packdir = install_root + '/packs/' + packdirn
        upd = make_global(packdir + '/.minecraft/assets', 'global/assets')
        moved += upd
        for mod in os.listdir(packdir + '/.minecraft/mods'):
            mods.add(mod)

    for mod in os.listdir(install_root + '/.modcache'):
        if mod not in mods:
            print("cleaning up %s" % mod)
            deleted += os.stat(install_root + '/.modcache/' + mod).st_size
            os.remove(install_root + '/.modcache/' + mod)

    # clean up launcher profiles
    with open(mcdir + '/launcher_profiles.json', 'r') as f:
        launcher_profiles = json.load(f)

    abs_packdir = pathlib.Path(os.path.abspath(install_root + '/packs'))
    to_remove = []
    for profname in launcher_profiles["profiles"]:
        prof = launcher_profiles["profiles"][profname]
        if "gameDir" in prof:
            abs_dir = pathlib.Path(prof["gameDir"])
            if abs_packdir in abs_dir.parents:
                # is modpack directory
                if not os.path.isdir(prof["gameDir"]):
                    # does not exist anymore
                    print("removing profile %s" % profname)
                    to_remove.append(profname)

    for profname in to_remove:
        launcher_profiles["profiles"].pop(profname)

    with open(mcdir + '/launcher_profiles.json', 'w') as f:
        json.dump(launcher_profiles, f, indent=2)

    print("Done! Deleted %.3f MiB of mods and migrated %d data folders" % \
        (deleted / 1048576, moved))

if __name__ == "__main__":
    main()


================================================
FILE: fabric_install.py
================================================
import os
import json
import sys
import requests
import subprocess
from util import *
import xml.etree.ElementTree as et

def get_latest_ver():
    url = 'https://maven.fabricmc.net/net/fabricmc/fabric-installer/maven-metadata.xml'
    outpath = '/tmp/fabric-versions.xml'
    resp = download(url, outpath)
    if resp != 200:
        print("Error %d trying to download Fabric version list" % resp)
        return None

    xml_tree = et.parse(outpath)
    root = xml_tree.getroot()

    ver_info = xml_tree.find('versioning')
    release_ver = ver_info.find('release')
    return release_ver.text

def main(manifest, mcver, mlver, packname, mc_dir, manual):
    print("Installing Fabric modloader")

    installer_ver = get_latest_ver()
    if installer_ver is None:
        print("Failed to acquire Fabric installer version")
        sys.exit(2)

    url = 'https://maven.fabricmc.net/net/fabricmc/fabric-installer/%s/fabric-installer-%s.jar' \
        % (installer_ver, installer_ver)
    outpath = '/tmp/fabric-%s-installer.jar' % installer_ver
    if not os.path.exists(outpath):
        resp = download(url, outpath)
        if resp != 200:
            print("Got error %d trying to download Fabric" % resp)
            sys.exit(2)

    args = ['java', '-jar', outpath, 'client', '-snapshot', '-dir', mc_dir,
            '-loader', mlver, '-mcversion', mcver]

    if manual:
        # I guess they want manual mode for some reason
        print("Using the manual installer!")
        print("***** NEW: INSTALL TO THE MAIN .MINECRAFT DIRECTORY *****")
        print("*****   (Just hit 'OK' with the default settings)   *****")
        subprocess.run(['java', '-jar', outpath])
    else:
        subprocess.run(args)

    if not os.path.exists(mc_dir + '/versions/' + get_version_id(mcver, mlver)):
        print("Forge installation failed.")
        sys.exit(3)

def get_version_id(mcver, mlver):
    return 'fabric-loader-%s-%s' % (mlver, mcver)


================================================
FILE: forge_install.py
================================================
#!/usr/bin/env python3
import os
import re
import subprocess
import sys
import time
from util import *

# https://files.minecraftforge.net/maven/net/minecraftforge/forge/1.12.2-14.23.5.2847/forge-1.12.2-14.23.5.2847-universal.jar

def get_forge_url(mcver, mlver):
    index_url = 'https://files.minecraftforge.net/net/minecraftforge/forge/index_%s.html' \
            % mcver

    outpath = '/tmp/forge-%s-index.html' % mcver
    if not os.path.exists(outpath):
        resp = download(index_url, outpath, False)
        if resp != 200:
            print("Got %d error trying to download Forge download index" % resp)
            return ""

    with open(outpath, 'r') as f:
        match = re.search(r"href=\".*(https://maven\.minecraftforge\.net/.*-%s-.*\.jar)\"" % mlver, f.read())
        if match:
            url = match.group(1)
        else:
            print("Could not find Forge download URL for version %s (Minecraft version %s)" % (mlver, mcver))
            return ""

    return url

def guess_forge_url(mcver, mlver):
    forge_fullver = mcver + '-' + mlver
    return 'https://maven.minecraftforge.net/net/minecraftforge/forge/%s/forge-%s-installer.jar' % (forge_fullver, forge_fullver)

def main(manifest, mcver, mlver, packname, mc_dir, manual):
    url_providers = [guess_forge_url, get_forge_url]

    outpath = '/tmp/forge-%s-installer.jar' % (mcver + '-' + mlver)

    for provider in url_providers:
        if os.path.exists(outpath):
            break
        url = provider(mcver, mlver)

        resp = download(url, outpath, True)
        if resp != 200:
            print("Got %d error trying to download Forge" % resp)

    if not os.path.exists(outpath):
        print("Failed to download the Forge installer.")
        sys.exit(2)

    # Run the Forge auto-install hack
    if manual:
        print("Using the manual installer!")
        print("***** NEW: INSTALL TO THE MAIN .MINECRAFT DIRECTORY *****")
        print("*****   (Just hit 'OK' with the default settings)   *****")
        for i in range(20):
            print("^ ", end="", flush=True)
            time.sleep(0.05)

        subprocess.run(['java', '-jar', outpath])
    else:
        compile_hack = False
        if not os.path.exists('ForgeHack.class'):
            compile_hack = True
        else:
            src_modtime = os.stat('ForgeHack.java').st_mtime
            cls_modtime = os.stat('ForgeHack.class').st_mtime
            if src_modtime > cls_modtime:
                print("hack source file updated, recompiling")
                compile_hack = True

        if compile_hack:
            subprocess.run(['javac', 'ForgeHack.java'])
        exit_code = subprocess.run(['java', 'ForgeHack', outpath, mc_dir]).returncode
        if exit_code != 0:
            print("Error running the auto-installer, try using --manual.")
            sys.exit(3)

    ver_id = get_version_id(mcver, mlver)
    if not os.path.exists(mc_dir + '/versions/' + ver_id):
        print("Forge installation not found.")
        if manual:
            print("Make sure you browsed to the correct minecraft directory.")
        print("Expected to find a directory named %s in %s" % (ver_id, mc_dir + '/versions'))
        print("If a similarly named directory was created in the expected folder, please submit a")
        print("bug report.")
        sys.exit(3)


def get_version_id(mcver, mlver):
    mcv_split = mcver.split('.')
    mcv = int(mcv_split[0]) * 1000 + int(mcv_split[1])
    mlv_split = mlver.split('.')
    mlv = int(mlv_split[-1]) # modloader patch version

    if mcv < 1008:
        # 1.7 (and possibly lower, haven't checked)
        return '%s-Forge%s-%s' % (mcver, mlver, mcver)
    elif mcv < 1010:
        # 1.8, 1.9
        return '%s-forge%s-%s-%s' % (mcver, mcver, mlver, mcver)
    elif mcv < 1012 or (mcv == 1012 and mlv < 2851):
        # 1.10, 1.11, 1.12 (up to 1.12.2-14.23.5.2847)
        return '%s-forge%s-%s' % (mcver, mcver, mlver)
    else:
        # 1.12.2-14.23.5.2851 and above
        return '%s-forge-%s' % (mcver, mlver)
        


================================================
FILE: install.py
================================================
#!/usr/bin/env python3
# CurseForge modpack installer
# This program is an alternative to the Twitch client, written for Linux users,
# so that they can install Minecraft modpacks from CurseForge.
# This tool requires that the user download the pack zip from CurseForge. It
# will then generate a complete Minecraft install directory with all of the
# mods and overrides installed.

import os
import sys
import json
import subprocess
import time
import random
import shutil
import argparse
import webbrowser
import pathlib

import forge_install
import fabric_install
import mod_download
from zipfile import ZipFile
from util import get_user_preference, set_user_preference

# shutil.copytree doesn't accept dirs_exist_ok until 3.8 which is fairly modern (I think some LTS distros still use 3.6)
# fall back to distutils copy_tree (the old solution) if needed
if sys.version_info.minor >= 8:
    import shutil
    def copy_tree(src, dest):
        shutil.copytree(src, dest, dirs_exist_ok=True)
else:
    from distutils.dir_util import copy_tree

# Files/directories to always copy when updating a modpack
# otherwise, only files and directories that don't exist in the new install will be copied
update_always_copy = [
    'options.txt',
    'optionsof.txt', # optifine options (may or may not exist)
    'servers.dat',
    'servers.dat_old',
    'screenshots'
]

def start_launcher(mc_dir):
    subprocess.run(['minecraft-launcher', '--workDir', os.path.abspath(mc_dir)])

def get_user_mcdir():
    # get the possibles minecraft home folder
    possible_homes = (
        os.getenv('HOME') + '/.minecraft',
        os.getenv('HOME') + '/.var/app/com.mojang.Minecraft/.minecraft/'
    )

    #remove unexistant paths
    possible_homes = [h for h in possible_homes if os.path.exists(h)]

    # no minecraft path found, ask the user to insert it
    if len(possible_homes) == 0:
        return input("No minecraft installation detected, please instert the .minecraft folder path (ctrl + c to cancel): ")

    # only one possible home has been found, just return it
    elif len(possible_homes) == 1:
        return possible_homes[0]

    # check if more than two paths exists, ask for the user which one should be used for install
    elif len(possible_homes) >= 2:
        while True:
            print("Multiple minecraft installations detected:")
            # print each folder with a number
            i = 1 # to have more natural numbers, we're starting to 1
            for home in possible_homes:
                print(i, "- ", home)
                i += 1

            #ask the user which one to use
            home = input("Which minecraft folder should be used: ")
            
            # if the user replied with something else than a number print an error and loop back
            if not home.isdigit():
                print("Error: the response should be a number!")
            
            # if the option doesn't exists, tell the user
            elif int(home)-1 > len(possible_homes):
                print("Error: this option doesn't exists!")
            
            # everything seems to be ok, returning the associated path
            else:
                return possible_homes[int(home)-1]

# try to create a directory and all of its parent directories if they do not exist (like mkdir -p)
def mkdirp(path):
    if type(path) != pathlib.Path:
        path = pathlib.Path(path) # convert to pathlib path if a string is provided
    try:
        path.mkdir(parents=True, exist_ok=True)
    except TypeError: # exist_ok not defined
        try:
            path.mkdir(parents=True)
        except FileExistsError:
            if not path.is_dir():
                raise # keep exception if a non-directory file exists here

def main(zipfile,
         user_mcdir=None, manual=False, open_browser=False,
         automated=False, sandbox=None, update_from=None):
    # check which minecraft folder to use
    if user_mcdir is None:
        # load the user preferences file
        user_mcdir = get_user_preference("minecraft_dir")

        # if the user didn't specify a minecraft folder, ask for it
        if user_mcdir is None:
            user_mcdir = get_user_mcdir()

    # check if the user wants to save the path as default if it's different from the one in the preferences
    pref_mcdir = get_user_preference("minecraft_dir")
    if user_mcdir != pref_mcdir and not automated:
        #ask the user if he wants to save the path as default
        print("Changes detected in the minecraft folder path. \n OLD: %s\n NEW: %s" % (pref_mcdir, user_mcdir))
        update_preferences = input("would you like to save this new path as default? (Y/n) ")

        # if the user wants to save the path as default, save it
        if update_preferences.lower().startswith("y") or update_preferences == "":
            set_user_preference("minecraft_dir", user_mcdir)
            print("Preferences updated! You can change them with the --mcdir option.")
        else:
            print("Okay, no updates were made.")

    # check if the minecraft dir should be moved into a sandbox
    sandbox_root = str(pathlib.Path(user_mcdir).parent) + '/modpack'
    sandbox_pref = get_user_preference("sandbox")
    should_sandbox = sandbox_pref
    # if the preference was not set, check it
    # - also check if the mcdir has changed
    if should_sandbox is None or user_mcdir != pref_mcdir:
        should_sandbox = ".var/app" in user_mcdir # flatpak paths look like this
    if sandbox is None:
        sandbox = should_sandbox
        # if this is new
        if sandbox and not sandbox_pref and not automated:
            # check if the user is ok with applying sandbox mode
            print("This minecraft directory seems to be installed from Flatpak. Since Flatpak apps")
            print("can't access the filesystem, 'sandbox mode' has been enabled, which will place ")
            print("modpacks alongside the main '.minecraft' installation so that they exist where ")
            print("the app can access them.")
            sandbox_ok = input("Is this OK? [Y/n]")
            if sandbox_ok.lower()[:1] not in ['y', '']:
                sandbox = False
    if sandbox != sandbox_pref:
        # update preference
        if sandbox:
            print("Enabling sandboxing - this can be changed with --no-sandbox if it breaks things")
            print("* Modpack data will be stored at %s" % sandbox_root)
        set_user_preference("sandbox", sandbox)

    install_root = sandbox_root if sandbox else '.'
    mkdirp(install_root)
    
    # Extract pack
    packname = os.path.splitext(zipfile)[0]
    packname = os.path.basename(packname)
    packdata_dir = '.packs/' + packname
    if os.path.isdir(packdata_dir):
        print("[pack data already unzipped]")
    else:
        mkdirp('.packs/')
        print("Extracting %s" % zipfile)
        with ZipFile(zipfile, 'r') as zf:
            zf.extractall(packdata_dir)

    # Generate minecraft environment
    mc_dir = install_root + '/packs/' + packname + '/.minecraft'
    if os.path.isdir(mc_dir):
        print("[minecraft dir already created]")
    else:
        print("Creating .minecraft directory")
        mkdirp(mc_dir)

        print("Creating symlinks")
        global_dir = install_root + '/global'
        mkdirp(global_dir + '/libraries')
        mkdirp(global_dir + '/resourcepacks')
        mkdirp(global_dir + '/saves')
        mkdirp(global_dir + '/shaderpacks')
        mkdirp(global_dir + '/assets')

        os.symlink(os.path.abspath(global_dir + '/libraries'), mc_dir + '/libraries', True)
        os.symlink(os.path.abspath(global_dir + '/resourcepacks'), mc_dir + '/resourcepacks', True)
        os.symlink(os.path.abspath(global_dir + '/saves'), mc_dir + '/saves', True)
        os.symlink(os.path.abspath(global_dir + '/shaderpacks'), mc_dir + '/shaderpacks', True)
        os.symlink(os.path.abspath(global_dir + '/assets'), mc_dir + '/assets', True)

    # Install Forge
    print("Installing modloader")
    try:
        with open(packdata_dir + '/manifest.json', 'r') as mf:
            manifest = json.load(mf)
    except (json.JsonDecodeError, OSError) as e:
        print("Manifest file not found or was corrupted.")
        print(e)
        return

    # supported modloaders and their run-functions
    # The run function will take the following arguments:
    # * manifest JSON
    # * minecraft version
    # * modloader version
    # * modpack name
    # * minecraft directory
    # * manual flag: run automatically or show GUI
    modloaders = {
        'forge': forge_install,
        'fabric': fabric_install
    }

    # I have not yet seen a modpack that has multiple modloaders
    if len(manifest['minecraft']['modLoaders']) != 1:
        print("This modpack (%s) has %d modloaders, instead of the normal 1."
                % (packname, len(manifest['minecraft']['modLoaders'])))
        print("This is currently unsupported, so expect the installation to fail in some way.")
        print("Please report which modpack caused this to the maintainer at:")
        print("  https://github.com/cdbbnnyCode/modpack-installer/issues")
    modloader, mlver = manifest['minecraft']['modLoaders'][0]["id"].split('-')
    mcver = manifest['minecraft']['version']

    if modloader not in modloaders:
        print("This modloader (%s) is not supported." % modloader)
        print("Currently, the only supported modloaders are %s" % modloaders)
        return

    print("Updating user launcher profiles")

    # user_mcdir = get_user_mcdir()
    with open(user_mcdir + '/launcher_profiles.json', 'r') as f:
        launcher_profiles = json.load(f)

    # add/overwrite the profile
    # TODO: add options for maximum memory
    # or config file for the java argument string
    ml_version_id = modloaders[modloader].get_version_id(mcver, mlver)
    launcher_profiles['profiles'][packname] = {
        "icon": "Chest",
        "javaArgs": "-Xmx4G -XX:+UnlockExperimentalVMOptions -XX:+UseG1GC -XX:G1NewSizePercent=20 -XX:G1ReservePercent=20 -XX:MaxGCPauseMillis=50 -XX:G1HeapRegionSize=32M",
        "lastVersionId": ml_version_id,
        "name": packname.replace('+', ' '),
        "gameDir": os.path.abspath(mc_dir),
        "type": "custom"
    }
    
    with open(user_mcdir + '/launcher_profiles.json', 'w') as f:
        json.dump(launcher_profiles, f, indent=2)

    if not os.path.exists(user_mcdir + '/versions/' + ml_version_id):
        modloaders[modloader].main(manifest, mcver, mlver, packname, user_mcdir, manual)
    else:
        print("[modloader already installed]")

    # Download mods
    if not os.path.exists(mc_dir + '/.mod_success'):
        modcache_dir = install_root + '/.modcache'
        mkdirp(mc_dir + '/mods')
        mkdirp(modcache_dir)
        print("Downloading mods")

        mods, manual_downloads = mod_download.main(packdata_dir + '/manifest.json', modcache_dir)
        if len(manual_downloads) > 0:
            while True:
                actual_manual_dls = [] # which ones aren't already downloaded
                for url, resp in manual_downloads:
                    outfile = resp[3]
                    if not os.path.exists(outfile):
                        actual_manual_dls.append((url, outfile))
                if len(actual_manual_dls) > 0:
                    print("====MANUAL DOWNLOAD REQUIRED====")
                    print("The following mods cannot be downloaded due to the new Project Distribution Toggle.")
                    print("Please download them manually; the files will be retrieved from your downloads directly.")
                    print("If there is a 404 error opening any of these links, try replacing 'legacy.curseforge.com' with 'www.curseforge.com'")
                    for url, outfile in actual_manual_dls:
                        print("* %s (%s)" % (url, os.path.basename(outfile)))

                    if open_browser:
                        browser = webbrowser.get()
                        for url, _ in actual_manual_dls:
                            browser.open_new(url)

                    # TODO save user's configured downloads folder somewhere
                    user_downloads_dir = os.environ['HOME'] + '/Downloads'
                    print("Retrieving downloads from %s - if that isn't your browser's download location, enter" \
                            % user_downloads_dir)
                    print("the correct location below. Otherwise, press Enter to continue.")
                    req_downloads_dir = input()

                    req_downloads_dir = os.path.expanduser(req_downloads_dir)
                    if len(req_downloads_dir) > 0:
                        if not os.path.isdir(req_downloads_dir):
                            print("- input directory is not a directory; ignoring")
                        else:
                            user_downloads_dir = req_downloads_dir
                    print("Finding files in %s..." % user_downloads_dir)
                    
                    for url, outfile in actual_manual_dls:
                        fname = os.path.basename(outfile)
                        fname_plus = fname.replace(' ', '+')
                        dl_path = user_downloads_dir + '/' + fname
                        dl_path_plus = user_downloads_dir + '/' + fname_plus
                        if os.path.exists(dl_path_plus):
                            print(dl_path_plus)
                            shutil.move(dl_path_plus, outfile)
                        elif os.path.exists(dl_path):
                            print(dl_path)
                            shutil.move(dl_path, outfile)
                else:
                    break

        # Link mods
        print("Linking mods")
        if not os.path.isdir(mc_dir + '/resources'):
            os.mkdir(mc_dir + '/resources')

        has_datapacks = False

        for mod in mods:
            jar = mod[0]
            ftype = mod[1]
            if ftype == 'mc-mods':
                modfile = mc_dir + '/mods/' + os.path.basename(jar)
                if not os.path.exists(modfile):
                    os.symlink(os.path.abspath(jar), modfile)
            elif ftype == 'texture-packs':
                print("Extracting texture pack %s" % jar)
                texpack_dir = '/tmp/%06d' % random.randint(0, 999999)
                os.mkdir(texpack_dir)
                with ZipFile(jar, 'r') as zf:
                    zf.extractall(texpack_dir)
                if os.path.exists(texpack_dir + '/data'):
                    # we have a data pack, don't extract it
                    has_datapacks = True
                    print("-> is actually data pack, placing into datapacks")
                    if not os.path.isdir(mc_dir + '/datapacks'):
                        os.mkdir(mc_dir + '/datapacks')
                    os.symlink(os.path.abspath(jar), mc_dir + '/datapacks/' + os.path.basename(jar))
                else:
                    for subdir in os.listdir(texpack_dir + '/assets'):
                        f = texpack_dir + '/assets/' + subdir
                        if os.path.isdir(f):
                            copy_tree(f, mc_dir + '/resources/' + subdir)
                        else:
                            shutil.copyfile(f, mc_dir + '/resources/' + subdir)
                shutil.rmtree(texpack_dir)
            else:
                print("Unknown file type %s" % ftype)

    else: # if mods already downloaded
        # assume there might be datapacks if a datapacks directory exists
        has_datapacks = os.path.isdir(mc_dir + '/datapacks')

    # Create success marker (does nothing if it already existed)
    with open(mc_dir + '/.mod_success', 'wb') as f:
        pass

    # Copy overrides
    print("Copying overrides")
    for subdir in os.listdir(packdata_dir + '/overrides'):
        print(subdir + "...")
        if os.path.isdir(packdata_dir + '/overrides/' + subdir):
            copy_tree(packdata_dir + '/overrides/' + subdir, mc_dir + '/' + subdir)
        else:
            shutil.copyfile(packdata_dir + '/overrides/' + subdir, mc_dir + '/' + subdir)

    # Copy files from old version if updating
    if update_from is not None:
        print("Updating from %s" % update_from)
        update_mcdir = update_from + '/.minecraft'
        if not os.path.isdir(update_mcdir):
            print("%s is not a valid modpack dir (.minecraft subdir not found)" % update_from)
            return

        # make sure 
        abs_update_dir = os.path.abspath(update_from)
        abs_pack_dir = os.path.abspath(install_root + '/packs')
        if pathlib.Path(abs_pack_dir) not in pathlib.Path(abs_update_dir).parents:
            print("%s is not a valid modpack dir (not inside %s)" % (update_from, abs_pack_dir))
            return

        to_copy = set()
        for path in update_always_copy:
            oldpath = update_mcdir + '/' + path
            if os.path.exists(oldpath):
                to_copy.add(path)
        for path in os.listdir(update_from):
            oldpath = update_mcdir + '/' + path
            newpath = mc_dir + '/' + path
            if os.path.exists(oldpath) and not os.path.exists(newpath):
                to_copy.add(path)

        for path in to_copy:
            print(path)
            oldpath = update_mcdir + '/' + path
            newpath = mc_dir + '/' + path
            
            if os.path.isdir(oldpath):
                copy_tree(oldpath, newpath)
            else:
                shutil.copyfile(oldpath, newpath)
        
        print("Removing old version")
        # remove directory
        shutil.rmtree(update_from)
        # run clean script
        import clean
        clean.main(user_mcdir, install_root)

    print("Done!")
    print()
    print()
    print()
    print("To launch your new modpack, just open the Minecraft launcher normally.")
    print("The modpack will be available in your installations list.")
    if has_datapacks:
        print("!!!! THIS MODPACK CONTAINS DATA PACKS !!!!")
        print("When creating a new world, please click the 'Data Packs' button and make sure the installed datapacks are present!")
        print("* Data packs have been stored in: " + os.path.abspath(mc_dir + '/datapacks'))
        print("* If there are no data packs shown, drag all of the zip files from this directory into your game window " \
              + "and make sure they are enabled for the world.")

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument('zipfile')
    parser.add_argument('--manual', dest='forge_disable', action='store_true'),
    parser.add_argument(
        '--mcdir', dest='mcdir',
        help="Minecraft directory, overrides stored preferences"
    )
    parser.add_argument(
        '--automated', dest='automated', action='store_true', 
        help="Intended for use by other scripts, limit blocking prompts"
    )
    parser.add_argument(
        '-b', '--open-browser', action="store_true", dest='open_browser',
        help='the browser to use to open the manual downloads'
    )
    parser.add_argument(
        '-s', '--sandbox', action='store_true', dest='sandbox', default=None,
        help="Force 'sandbox' mode (for Flatpak etc.), places files alongside .minecraft dir so that they are"
            +"accessible from inside a sandboxed environment"
    )
    parser.add_argument(
        '--no-sandbox', action='store_false', dest='sandbox', default=None,
        help="Force-disable 'sandbox' mode"
    )
    parser.add_argument(
        '--update', dest='old_modpack', default=None,
        help="Install modpack over existing version (copies settings from old pack directory before deleting it)"
    )
    args = parser.parse_args(sys.argv[1:])
    main(
        args.zipfile,
        user_mcdir=args.mcdir,
        manual=args.forge_disable,
        automated=args.automated,
        open_browser=args.open_browser,
        sandbox=args.sandbox,
        update_from=args.old_modpack
    )


================================================
FILE: migrate.py
================================================
import os
import json
import shutil
import sys

def main():
    user_mcdir = os.getenv('HOME') + '/.minecraft'
    if len(sys.argv) > 1:
        if sys.argv[1] == '-h' or sys.argv[1] == '--help':
            print("Usage:")
            print("    %s -h|--help    Print this help message")
            print("    %s [mcdir]      Migrate modpack launcher data")
            return
        else:
            user_mcdir = sys.argv[1]

    with open(user_mcdir + '/launcher_profiles.json', 'r') as f:
        launcher_profiles = json.load(f)

    for packdirn in os.listdir('packs/'):
        print(packdirn)
        pack_profiles_file = 'packs/' + packdirn + '/.minecraft/launcher_profiles.json'
        if os.path.exists(pack_profiles_file):
            with open(pack_profiles_file, 'r') as f:
                old_profiles = json.load(f)
            
            found = False
            for profname in old_profiles['profiles']:
                profile = old_profiles['profiles'][profname]
                if profile['type'] == 'custom' or profile['type'] == '':
                    found = True
                    # print(profile)

                    version = profile['lastVersionId']
                    launcher_profiles['profiles'][packdirn] = {
                        "icon": "Chest",
                        "javaArgs": "-Xmx4G -XX:+UnlockExperimentalVMOptions -XX:+UseG1GC -XX:G1NewSizePercent=20 -XX:G1ReservePercent=20 -XX:MaxGCPauseMillis=50 -XX:G1HeapRegionSize=32M",
                        "lastVersionId": version,
                        "name": packdirn.replace('+', ' '),
                        "gameDir": os.path.abspath('packs/' + packdirn + '/.minecraft'),
                        "type": "custom"
                    }

                    launcher_dir = 'packs/' + packdirn + '/.minecraft/launcher'
                    print("add profile %s -- version %s" % (packdirn, version))
                    if os.path.exists(launcher_dir):
                        print("remove launcher directory")
                        shutil.rmtree(launcher_dir)

                    # copy the version json
                    version_dir = user_mcdir + '/versions/' + version
                    if not os.path.exists(version_dir):
                        print("copying version info")
                        os.mkdir(version_dir)
                        shutil.copy2('packs/' + packdirn + '/.minecraft/versions/' + version + '/' + version + '.json',
                            version_dir)

            if not found:
                print("failed to migrate modpack %s, no launcher profile found" % packdirn)

    print("copying libraries")
    if os.path.exists('global/libraries'):
        shutil.copytree('global/libraries', user_mcdir + '/libraries', dirs_exist_ok=True)
    
    with open(user_mcdir + '/launcher_profiles.json', 'w') as f:
        json.dump(launcher_profiles, f, indent=2)
    # print(json.dumps(launcher_profiles, indent=2))

if __name__ == "__main__":
    main()

        

================================================
FILE: mod_download.py
================================================
#!/usr/bin/env python3
import os
import sys
import requests
import json
import asyncio
import time
from util import download
from concurrent.futures import ThreadPoolExecutor

api_url = 'https://api.curseforge.com/v1'
# NOTE: Modified and/or forked versions of this project must not use this API key.
# Instead, please apply for a new API key from CurseForge's website.
api_key = '$2a$10$t2BUHi3wKkiMw1YEqItui.XaHDvw4yMLK2peaKGkI9ufv3IsYRlkW'

# temporary rate limit before CF implements a real one
api_ratelimit = 20 # JSON requests per second
req_history = [0, 0] # time, request count so far

def get_json(session, url):
    r = session.get(url)
    if r.status_code != 200:
        print("Error %d trying to access %s" % (r.status_code, url))
        print(r.text)
        return None

    req_history[1] += 1
    while req_history[1] >= api_ratelimit:
        if time.perf_counter() > req_history[0] + 1:
            req_history[0] = time.perf_counter()
            req_history[1] = 0
            break
        s_remaining = max(0, req_history[0] + 1 - time.perf_counter())
        print("rate limiting (%.3fs)" % s_remaining)
        time.sleep(s_remaining)

    return json.loads(r.text)

def fetch_mod(session, f, out_dir):
    pid = f['projectID']
    fid = f['fileID']
    project_info = get_json(session, api_url + ('/mods/%d' % pid))
    if project_info is None:
        print("fetch failed")
        return (f, 'error')
    project_info = project_info['data']

    # print(project_info)
    print(project_info['links']['websiteUrl'])
    file_type = project_info['links']['websiteUrl'].split('/')[4] # mc-mods or texture-packs
    info = get_json(session, api_url + ('/mods/%d/files/%d' % (pid, fid)))
    if info is None:
        print("fetch failed")
        return (f, 'error')
    info = info['data']

    fn = info['fileName']
    dl = info['downloadUrl']
    out_file = out_dir + '/' + fn

    if not project_info['allowModDistribution']:
        print("distribution disabled for this mod")
        return (f, 'dist-error', project_info, out_file, file_type)

    if os.path.exists(out_file):
        if os.path.getsize(out_file) == info['fileLength']:
            print("%s OK" % fn)
            return (out_file, file_type)
    
    status = download(dl, out_file, session=session, progress=True)
    if status != 200:
        print("download failed (error %d)" % status)
        return (f, 'error')
    return (out_file, file_type)

async def download_mods_async(manifest, out_dir):
    with ThreadPoolExecutor(max_workers=1) as executor, \
            requests.Session() as session:
        session.headers['X-Api-Key'] = api_key
        loop = asyncio.get_event_loop()
        tasks = []
        for f in manifest['files']:
            task = loop.run_in_executor(executor, fetch_mod, *(session, f, out_dir))
            tasks.append(task)

        jars = []
        manual_downloads = []
        while len(tasks) > 0:
            retry_tasks = []

            for resp in await asyncio.gather(*tasks):
                if resp[1] == 'error':
                    print("failed to fetch %s, retrying later" % resp[0])
                    retry_tasks.append(resp[0])
                elif resp[1] == 'dist-error':
                    manual_dl_url = resp[2]['links']['websiteUrl'] + '/download/' + str(resp[0]['fileID'])
                    manual_dl_url = manual_dl_url.replace('www.curseforge.com', 'legacy.curseforge.com')
                    manual_downloads.append((manual_dl_url, resp))
                    # add to jars list so that the file gets linked
                    jars.append(resp[3:])
                else:
                    jars.append(resp)

            tasks = []
            if len(retry_tasks) > 0:
                print("retrying...")
                time.sleep(2)
            for f in retry_tasks:
                tasks.append(loop.run_in_executor(executor, fetch_mod, *(session, f, out_dir)))
        return jars, manual_downloads


def main(manifest_json, mods_dir):
    mod_jars = []
    with open(manifest_json, 'r') as f:
        manifest = json.load(f)

    print("Downloading mods")

    loop = asyncio.get_event_loop()
    future = asyncio.ensure_future(download_mods_async(manifest, mods_dir))
    loop.run_until_complete(future)
    return future.result()

if __name__ == "__main__":
    print(main(sys.argv[1], sys.argv[2]))


================================================
FILE: util.py
================================================
# utility functions
import requests
import shutil
import json
import os

def status_bar(text, progress, bar_width=0.5, show_percent=True, borders='[]', progress_ch='#', space_ch=' '):
    ansi_el = '\x1b[K\r' # escape code to clear the rest of the line plus carriage return
    term_width = shutil.get_terminal_size().columns
    if term_width < 10:
        print(end=ansi_el)
        return
    bar_width_c = max(int(term_width * bar_width), 4)
    text_width = min(term_width - bar_width_c - 6, len(text)) # subract 4 characters for percentage and 2 spaces
    text_part = '' if (text_width == 0) else text[-text_width:]
    
    progress_c = int(progress * (bar_width_c - 2))
    remaining_c = bar_width_c - 2 - progress_c
    padding_c = max(0, term_width - bar_width_c - text_width - 6)

    bar = borders[0] + progress_ch * progress_c + space_ch * remaining_c + borders[1]
    pad = ' ' * padding_c
    print("%s %s%3.0f%% %s" % (text_part, pad, (progress * 100), bar), end=ansi_el)
    
def download(url, dest, progress=False, session=None):
    print("Downloading %s" % url)

    try:
        if session is not None:
            r = session.get(url, stream=True)
        else:
            r = requests.get(url, stream=True)
        
        if r.status_code != 200:
            return r.status_code
        
        # size is only for the progress bar
        size = int(r.headers.get('Content-Length', 1))

        with open(dest, 'wb') as f:
            if progress:
                n = 0
                for chunk in r.iter_content(1048576):
                    f.write(chunk)
                    n += len(chunk)
                    status_bar(url, min(n / size, 1))
            else:
                f.write(r.content)
    except requests.RequestException as e:
        print("Download failed with an internal error:")
        print(repr(e))
        return -1
    except OSError as e:
        print("Download failed with an OS error:")
        print(repr(e))
        return -2

    if progress:
        print()
    
    return r.status_code

def rename_profile(launcher_profiles, orig_name, new_name):
    orig_profile = launcher_profiles['profiles'][orig_name].copy()
    del launcher_profiles['profiles'][orig_name]
    launcher_profiles['profiles'][new_name] = orig_profile
    launcher_profiles['profiles'][new_name]['name'] = new_name

def __user_preferences_file():
    # create the user preferences file if it doesn't exist
    if not os.path.isfile('user-preferences.json'):
        with open('user-preferences.json', 'w') as f:
            json.dump({}, f)

def get_user_preference(key):
    __user_preferences_file()

    # load the user preferences file
    with open('user-preferences.json', 'r') as f:
        prefs = json.load(f)
    
    # return the value if it exists, otherwise return None
    if key not in prefs:
        return None
    return prefs[key]

def set_user_preference(key, value):
    __user_preferences_file()

    # load the user preferences file
    with open('user-preferences.json', 'r') as f:
        prefs = json.load(f)

    # set the value and save the file
    prefs[key] = value
    with open('user-preferences.json', 'w') as f:
        json.dump(prefs, f, indent=4)
Download .txt
gitextract_epl3pjam/

├── .gitignore
├── ForgeHack.java
├── LICENSE
├── README.md
├── clean.py
├── fabric_install.py
├── forge_install.py
├── install.py
├── migrate.py
├── mod_download.py
└── util.py
Download .txt
SYMBOL INDEX (33 symbols across 8 files)

FILE: ForgeHack.java
  class ForgeHack (line 13) | public class ForgeHack
    method getClassLoader (line 15) | private static ClassLoader getClassLoader(String jarfile)
    class MInvocationHandler (line 28) | private static class MInvocationHandler implements InvocationHandler
      method invoke (line 30) | public Object invoke(Object proxy, Method method, Object[] args)
    method run_v1 (line 58) | private static boolean run_v1(File target, ClassLoader loader)
    method run_v15 (line 77) | private static boolean run_v15(File target, ClassLoader loader)
    method run_v2 (line 107) | private static boolean run_v2(File target, File jarfile, ClassLoader l...
    method main (line 169) | public static void main(String[] args) throws Exception

FILE: clean.py
  function make_global (line 8) | def make_global(dir, gdir):
  function main (line 22) | def main(override_mcdir=None, override_inst_root=None):

FILE: fabric_install.py
  function get_latest_ver (line 9) | def get_latest_ver():
  function main (line 24) | def main(manifest, mcver, mlver, packname, mc_dir, manual):
  function get_version_id (line 57) | def get_version_id(mcver, mlver):

FILE: forge_install.py
  function get_forge_url (line 11) | def get_forge_url(mcver, mlver):
  function guess_forge_url (line 32) | def guess_forge_url(mcver, mlver):
  function main (line 36) | def main(manifest, mcver, mlver, packname, mc_dir, manual):
  function get_version_id (line 93) | def get_version_id(mcver, mlver):

FILE: install.py
  function copy_tree (line 30) | def copy_tree(src, dest):
  function start_launcher (line 45) | def start_launcher(mc_dir):
  function get_user_mcdir (line 48) | def get_user_mcdir():
  function mkdirp (line 92) | def mkdirp(path):
  function main (line 104) | def main(zipfile,

FILE: migrate.py
  function main (line 6) | def main():

FILE: mod_download.py
  function get_json (line 20) | def get_json(session, url):
  function fetch_mod (line 39) | def fetch_mod(session, f, out_dir):
  function download_mods_async (line 76) | async def download_mods_async(manifest, out_dir):
  function main (line 113) | def main(manifest_json, mods_dir):

FILE: util.py
  function status_bar (line 7) | def status_bar(text, progress, bar_width=0.5, show_percent=True, borders...
  function download (line 25) | def download(url, dest, progress=False, session=None):
  function rename_profile (line 63) | def rename_profile(launcher_profiles, orig_name, new_name):
  function __user_preferences_file (line 69) | def __user_preferences_file():
  function get_user_preference (line 75) | def get_user_preference(key):
  function set_user_preference (line 87) | def set_user_preference(key, value):
Condensed preview — 11 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (63K chars).
[
  {
    "path": ".gitignore",
    "chars": 253,
    "preview": "# Python\n__pycache__/\n\n# Java\n*.class\n\n# NodeJS\nnode_modules/\n\n# Atom\n.ropeproject/\n\n# Misc. log files\n*.log\n\n# Modpack "
  },
  {
    "path": "ForgeHack.java",
    "chars": 6484,
    "preview": "// Minecraft Forge Installer Hack\n// Bypasses the GUI and installs the Forge client to the specified location\nimport jav"
  },
  {
    "path": "LICENSE",
    "chars": 1074,
    "preview": "The MIT License\n\nCopyright 2020-2023 Aidan Yaklin\n\nPermission is hereby granted, free of charge, to any person obtaining"
  },
  {
    "path": "README.md",
    "chars": 13364,
    "preview": "## Modpack Installer  \n###### V2.3.5\n\nThis command-line tool allows easy installation of CurseForge modpacks on Linux\nsy"
  },
  {
    "path": "clean.py",
    "chars": 2856,
    "preview": "import os\nimport shutil\nimport pathlib\nimport json\n\nfrom util import get_user_preference\n\ndef make_global(dir, gdir):\n  "
  },
  {
    "path": "fabric_install.py",
    "chars": 1953,
    "preview": "import os\nimport json\nimport sys\nimport requests\nimport subprocess\nfrom util import *\nimport xml.etree.ElementTree as et"
  },
  {
    "path": "forge_install.py",
    "chars": 4059,
    "preview": "#!/usr/bin/env python3\nimport os\nimport re\nimport subprocess\nimport sys\nimport time\nfrom util import *\n\n# https://files."
  },
  {
    "path": "install.py",
    "chars": 20037,
    "preview": "#!/usr/bin/env python3\n# CurseForge modpack installer\n# This program is an alternative to the Twitch client, written for"
  },
  {
    "path": "migrate.py",
    "chars": 3008,
    "preview": "import os\nimport json\nimport shutil\nimport sys\n\ndef main():\n    user_mcdir = os.getenv('HOME') + '/.minecraft'\n    if le"
  },
  {
    "path": "mod_download.py",
    "chars": 4383,
    "preview": "#!/usr/bin/env python3\nimport os\nimport sys\nimport requests\nimport json\nimport asyncio\nimport time\nfrom util import down"
  },
  {
    "path": "util.py",
    "chars": 3222,
    "preview": "# utility functions\nimport requests\nimport shutil\nimport json\nimport os\n\ndef status_bar(text, progress, bar_width=0.5, s"
  }
]

About this extraction

This page contains the full source code of the cdbbnnyCode/modpack-installer GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 11 files (59.3 KB), approximately 14.4k tokens, and a symbol index with 33 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!