Merge commit '7fa442fb28a2b9156690ff147206275163e7aec8' into beta
@@ -41,7 +41,7 @@
|
||||
{
|
||||
"name": "display_claims!: serde_json::Value",
|
||||
"ordinal": 7,
|
||||
"type_info": "Null"
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar\n FROM settings\n ",
|
||||
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates\n FROM settings\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -142,6 +142,21 @@
|
||||
"name": "toggle_sidebar",
|
||||
"ordinal": 27,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "skipped_update",
|
||||
"ordinal": 28,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "pending_update_toast_for_version",
|
||||
"ordinal": 29,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "auto_download_updates",
|
||||
"ordinal": 30,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -175,8 +190,11 @@
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
false
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "5193f519f021b2e7013cdb67a6e1a31ae4bd7532d02f8b00b43d5645351941ca"
|
||||
"hash": "7dc83d7ffa3d583fc5ffaf13811a8dab4d0b9ded6200f827b9de7ac32e5318d5"
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27,\n hide_nametag_skins_page = $28\n ",
|
||||
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27,\n hide_nametag_skins_page = $28,\n\n skipped_update = $29,\n pending_update_toast_for_version = $30,\n auto_download_updates = $31\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 28
|
||||
"Right": 31
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3613473fb4d836ee0fb3c292e6bf5e50912064c29ebf1a1e5ead79c44c37e64c"
|
||||
"hash": "eb95fac3043d0ffd10caef69cc469474cc5c0d36cc0698c4cc0852da81fed158"
|
||||
}
|
||||
@@ -1,127 +1,129 @@
|
||||
[package]
|
||||
name = "theseus"
|
||||
version = "1.0.0-local" # The actual version is set by the theseus-build workflow on tagging
|
||||
authors = ["Jai A <jaiagr+gpg@pm.me>"]
|
||||
# The actual version is set by the theseus-build workflow on tagging
|
||||
version = "1.0.0-local"
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ariadne = { workspace = true }
|
||||
async-compression = { workspace = true, features = ["gzip", "tokio"] }
|
||||
async-recursion = { workspace = true }
|
||||
async-tungstenite = { workspace = true, features = [
|
||||
"tokio-runtime",
|
||||
"tokio-rustls-webpki-roots",
|
||||
] }
|
||||
async-walkdir = { workspace = true }
|
||||
async_zip = { workspace = true, features = [
|
||||
"bzip2",
|
||||
"chrono",
|
||||
"deflate",
|
||||
"deflate64",
|
||||
"tokio-fs",
|
||||
"zstd",
|
||||
] }
|
||||
base64 = { workspace = true }
|
||||
bytemuck = { workspace = true, features = ["extern_crate_alloc"] }
|
||||
bytes = { workspace = true, features = ["serde"] }
|
||||
chardetng = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
daedalus = { workspace = true }
|
||||
dashmap = { workspace = true, features = ["serde"] }
|
||||
data-url = { workspace = true }
|
||||
derive_more = { workspace = true, features = ["display"] }
|
||||
dirs = { workspace = true }
|
||||
discord-rich-presence = { workspace = true }
|
||||
dunce = { workspace = true }
|
||||
either = { workspace = true }
|
||||
encoding_rs = { workspace = true }
|
||||
enumset = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
fs4 = { workspace = true, features = ["tokio"] }
|
||||
futures = { workspace = true, features = ["alloc", "async-await"] }
|
||||
heck = { workspace = true }
|
||||
hickory-resolver = { workspace = true }
|
||||
indicatif = { workspace = true, optional = true }
|
||||
itertools = { workspace = true }
|
||||
notify = { workspace = true }
|
||||
notify-debouncer-mini = { workspace = true }
|
||||
p256 = { workspace = true, features = ["ecdsa"] }
|
||||
paste = { workspace = true }
|
||||
path-util = { workspace = true }
|
||||
phf = { workspace = true }
|
||||
png = { workspace = true }
|
||||
quartz_nbt = { workspace = true, features = ["serde"] }
|
||||
quick-xml = { workspace = true, features = ["async-tokio"] }
|
||||
rand = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
reqwest = { workspace = true, features = [
|
||||
"brotli",
|
||||
"charset",
|
||||
"deflate",
|
||||
"gzip",
|
||||
"http2",
|
||||
"json",
|
||||
"macos-system-configuration",
|
||||
"multipart",
|
||||
"rustls-tls-webpki-roots",
|
||||
"stream",
|
||||
] }
|
||||
rgb = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
serde_ini.workspace = true
|
||||
serde_with.workspace = true
|
||||
sha1_smol.workspace = true
|
||||
sha2.workspace = true
|
||||
serde_ini = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_with = { workspace = true }
|
||||
sha1_smol = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
sqlx = { workspace = true, features = [
|
||||
"json",
|
||||
"macros",
|
||||
"migrate",
|
||||
"runtime-tokio",
|
||||
"sqlite",
|
||||
"uuid",
|
||||
] }
|
||||
sysinfo = { workspace = true, features = ["disk", "system"] }
|
||||
tauri = { workspace = true, features = ["unstable"], optional = true }
|
||||
tempfile = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
"fs",
|
||||
"io-util",
|
||||
"macros",
|
||||
"net",
|
||||
"process",
|
||||
"sync",
|
||||
"time",
|
||||
] }
|
||||
tokio-util = { workspace = true, features = [
|
||||
"compat",
|
||||
"io",
|
||||
"io-util",
|
||||
"time",
|
||||
] }
|
||||
tracing = { workspace = true }
|
||||
tracing-error = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["chrono", "env-filter"] }
|
||||
url = { workspace = true, features = ["serde"] }
|
||||
uuid = { workspace = true, features = ["serde", "v4"] }
|
||||
zip.workspace = true
|
||||
async_zip = { workspace = true, features = [
|
||||
"chrono",
|
||||
"tokio-fs",
|
||||
"deflate",
|
||||
"bzip2",
|
||||
"zstd",
|
||||
"deflate64",
|
||||
] }
|
||||
flate2.workspace = true
|
||||
tempfile.workspace = true
|
||||
dashmap = { workspace = true, features = ["serde"] }
|
||||
quick-xml = { workspace = true, features = ["async-tokio"] }
|
||||
enumset.workspace = true
|
||||
chardetng.workspace = true
|
||||
encoding_rs.workspace = true
|
||||
hashlink.workspace = true
|
||||
png.workspace = true
|
||||
bytemuck.workspace = true
|
||||
rgb.workspace = true
|
||||
phf.workspace = true
|
||||
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
daedalus.workspace = true
|
||||
dirs.workspace = true
|
||||
|
||||
regex.workspace = true
|
||||
sysinfo = { workspace = true, features = ["system", "disk"] }
|
||||
thiserror.workspace = true
|
||||
either.workspace = true
|
||||
data-url.workspace = true
|
||||
|
||||
tracing.workspace = true
|
||||
tracing-subscriber = { workspace = true, features = ["chrono", "env-filter"] }
|
||||
tracing-error.workspace = true
|
||||
|
||||
paste.workspace = true
|
||||
heck.workspace = true
|
||||
|
||||
tauri = { workspace = true, optional = true, features = ["unstable"] }
|
||||
indicatif = { workspace = true, optional = true }
|
||||
|
||||
async-tungstenite = { workspace = true, features = ["tokio-runtime", "tokio-rustls-webpki-roots"] }
|
||||
futures = { workspace = true, features = ["async-await", "alloc"] }
|
||||
reqwest = { workspace = true, features = [
|
||||
"json",
|
||||
"stream",
|
||||
"deflate",
|
||||
"gzip",
|
||||
"brotli",
|
||||
"rustls-tls-webpki-roots",
|
||||
"charset",
|
||||
"http2",
|
||||
"macos-system-configuration",
|
||||
"multipart",
|
||||
] }
|
||||
tokio = { workspace = true, features = [
|
||||
"time",
|
||||
"io-util",
|
||||
"net",
|
||||
"sync",
|
||||
"fs",
|
||||
"macros",
|
||||
"process",
|
||||
] }
|
||||
tokio-util = { workspace = true, features = ["compat", "io", "io-util"] }
|
||||
async-recursion.workspace = true
|
||||
fs4 = { workspace = true, features = ["tokio"] }
|
||||
async-walkdir.workspace = true
|
||||
async-compression = { workspace = true, features = ["tokio", "gzip"] }
|
||||
|
||||
notify.workspace = true
|
||||
notify-debouncer-mini.workspace = true
|
||||
|
||||
dunce.workspace = true
|
||||
|
||||
whoami.workspace = true
|
||||
|
||||
discord-rich-presence.workspace = true
|
||||
|
||||
p256 = { workspace = true, features = ["ecdsa"] }
|
||||
rand.workspace = true
|
||||
base64.workspace = true
|
||||
|
||||
sqlx = { workspace = true, features = [
|
||||
"runtime-tokio",
|
||||
"sqlite",
|
||||
"macros",
|
||||
"migrate",
|
||||
"json",
|
||||
"uuid",
|
||||
] }
|
||||
|
||||
quartz_nbt = { workspace = true, features = ["serde"] }
|
||||
hickory-resolver.workspace = true
|
||||
|
||||
ariadne.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg.workspace = true
|
||||
whoami = { workspace = true }
|
||||
zbus = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
dotenvy.workspace = true
|
||||
dunce.workspace = true
|
||||
dotenvy = { workspace = true }
|
||||
dunce = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
cidre = { workspace = true, features = ["blocks", "nw"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { workspace = true, features = ["Networking_Connectivity"] }
|
||||
windows-core = { workspace = true }
|
||||
winreg = { workspace = true }
|
||||
|
||||
[features]
|
||||
tauri = ["dep:tauri"]
|
||||
cli = ["dep:indicatif"]
|
||||
tauri = ["dep:tauri"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -53,7 +53,6 @@ fn build_java_jars() {
|
||||
.arg("build")
|
||||
.arg("--no-daemon")
|
||||
.arg("--console=rich")
|
||||
.arg("--info")
|
||||
.current_dir(dunce::canonicalize("java").unwrap())
|
||||
.status()
|
||||
.expect("Failed to wait on Gradle build");
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowCopyAction
|
||||
import com.github.jengelman.gradle.plugins.shadow.transformers.CacheableTransformer
|
||||
import com.github.jengelman.gradle.plugins.shadow.transformers.ResourceTransformer
|
||||
import com.github.jengelman.gradle.plugins.shadow.transformers.TransformerContext
|
||||
import org.apache.tools.zip.ZipEntry
|
||||
import org.apache.tools.zip.ZipOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.jar.JarFile
|
||||
import java.util.jar.Attributes as JarAttributes
|
||||
import java.util.jar.Manifest as JarManifest
|
||||
|
||||
plugins {
|
||||
java
|
||||
id("com.diffplug.spotless") version "7.0.4"
|
||||
@@ -11,6 +22,7 @@ repositories {
|
||||
dependencies {
|
||||
implementation("org.ow2.asm:asm:9.8")
|
||||
implementation("org.ow2.asm:asm-tree:9.8")
|
||||
implementation("com.google.code.gson:gson:2.13.1")
|
||||
|
||||
testImplementation(libs.junit.jupiter)
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
@@ -46,6 +58,50 @@ tasks.shadowJar {
|
||||
|
||||
enableRelocation = true
|
||||
relocationPrefix = "com.modrinth.theseus.shadow"
|
||||
|
||||
// Adapted from ManifestResourceTransformer to do one thing: remove Multi-Release.
|
||||
// Multi-Release gets added by shadow because gson has Multi-Release set to true, however
|
||||
// shadow strips the actual versions directory, as gson only has a module-info.class in there.
|
||||
// However, older versions of SecureJarHandler crash if Multi-Release is set to true but the
|
||||
// versions directory is missing.
|
||||
transform(@CacheableTransformer object : ResourceTransformer {
|
||||
private var manifestDiscovered = false
|
||||
private var manifest: JarManifest? = null
|
||||
|
||||
override fun canTransformResource(element: FileTreeElement): Boolean {
|
||||
return JarFile.MANIFEST_NAME.equals(element.path, ignoreCase = true)
|
||||
}
|
||||
|
||||
override fun transform(context: TransformerContext) {
|
||||
if (!manifestDiscovered) {
|
||||
try {
|
||||
manifest = JarManifest(context.inputStream)
|
||||
manifestDiscovered = true
|
||||
} catch (e: IOException) {
|
||||
logger.warn("Failed to read MANIFEST.MF", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun hasTransformedResource(): Boolean = true
|
||||
|
||||
override fun modifyOutputStream(
|
||||
os: ZipOutputStream,
|
||||
preserveFileTimestamps: Boolean
|
||||
) {
|
||||
// If we didn't find a manifest, then let's create one.
|
||||
if (manifest == null) {
|
||||
manifest = JarManifest()
|
||||
}
|
||||
|
||||
manifest!!.mainAttributes.remove(JarAttributes.Name.MULTI_RELEASE)
|
||||
|
||||
os.putNextEntry(ZipEntry(JarFile.MANIFEST_NAME).apply {
|
||||
time = ShadowCopyAction.CONSTANT_TIME_FOR_ZIP_ENTRIES
|
||||
})
|
||||
manifest!!.write(os)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
tasks.named<Test>("test") {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package com.modrinth.theseus;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import com.modrinth.theseus.rpc.RpcHandlers;
|
||||
import com.modrinth.theseus.rpc.TheseusRpc;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.AccessibleObject;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public final class MinecraftLaunch {
|
||||
public static void main(String[] args) throws IOException, ReflectiveOperationException {
|
||||
@@ -13,45 +15,19 @@ public final class MinecraftLaunch {
|
||||
final String[] gameArgs = Arrays.copyOfRange(args, 1, args.length);
|
||||
|
||||
System.setProperty("modrinth.process.args", String.join("\u001f", gameArgs));
|
||||
parseInput();
|
||||
|
||||
final CompletableFuture<Void> waitForLaunch = new CompletableFuture<>();
|
||||
TheseusRpc.connectAndStart(
|
||||
System.getProperty("modrinth.internal.ipc.host"),
|
||||
Integer.getInteger("modrinth.internal.ipc.port"),
|
||||
new RpcHandlers()
|
||||
.handler("set_system_property", String.class, String.class, System::setProperty)
|
||||
.handler("launch", () -> waitForLaunch.complete(null)));
|
||||
|
||||
waitForLaunch.join();
|
||||
relaunch(mainClass, gameArgs);
|
||||
}
|
||||
|
||||
private static void parseInput() throws IOException {
|
||||
final ByteArrayOutputStream line = new ByteArrayOutputStream();
|
||||
while (true) {
|
||||
final int b = System.in.read();
|
||||
if (b < 0) {
|
||||
throw new IllegalStateException("Stdin terminated while parsing");
|
||||
}
|
||||
if (b != '\n') {
|
||||
line.write(b);
|
||||
continue;
|
||||
}
|
||||
if (handleLine(line.toString("UTF-8"))) {
|
||||
break;
|
||||
}
|
||||
line.reset();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean handleLine(String line) {
|
||||
final String[] parts = line.split("\t", 2);
|
||||
switch (parts[0]) {
|
||||
case "property": {
|
||||
final String[] keyValue = parts[1].split("\t", 2);
|
||||
System.setProperty(keyValue[0], keyValue[1]);
|
||||
return false;
|
||||
}
|
||||
case "launch":
|
||||
return true;
|
||||
}
|
||||
|
||||
System.err.println("Unknown input line " + line);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void relaunch(String mainClassName, String[] args) throws ReflectiveOperationException {
|
||||
final int javaVersion = getJavaVersion();
|
||||
final Class<?> mainClass = Class.forName(mainClassName);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.modrinth.theseus.rpc;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonNull;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
public class RpcHandlers {
|
||||
private final Map<String, Function<JsonElement[], JsonElement>> handlers = new HashMap<>();
|
||||
private boolean frozen;
|
||||
|
||||
public RpcHandlers handler(String functionName, Runnable handler) {
|
||||
return addHandler(functionName, args -> {
|
||||
handler.run();
|
||||
return JsonNull.INSTANCE;
|
||||
});
|
||||
}
|
||||
|
||||
public <A, B> RpcHandlers handler(
|
||||
String functionName, Class<A> arg1Type, Class<B> arg2Type, BiConsumer<A, B> handler) {
|
||||
return addHandler(functionName, args -> {
|
||||
if (args.length != 2) {
|
||||
throw new IllegalArgumentException(functionName + " expected 2 arguments");
|
||||
}
|
||||
final A arg1 = TheseusRpc.GSON.fromJson(args[0], arg1Type);
|
||||
final B arg2 = TheseusRpc.GSON.fromJson(args[1], arg2Type);
|
||||
handler.accept(arg1, arg2);
|
||||
return JsonNull.INSTANCE;
|
||||
});
|
||||
}
|
||||
|
||||
private RpcHandlers addHandler(String functionName, Function<JsonElement[], JsonElement> handler) {
|
||||
if (frozen) {
|
||||
throw new IllegalStateException("Cannot add handler to frozen RpcHandlers instance");
|
||||
}
|
||||
handlers.put(functionName, handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
Map<String, Function<JsonElement[], JsonElement>> build() {
|
||||
frozen = true;
|
||||
return handlers;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.modrinth.theseus.rpc;
|
||||
|
||||
public class RpcMethodException extends RuntimeException {
|
||||
private static final long serialVersionUID = 1922360184188807964L;
|
||||
|
||||
public RpcMethodException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.modrinth.theseus.rpc;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import java.io.*;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Function;
|
||||
|
||||
public final class TheseusRpc {
|
||||
static final Gson GSON = new GsonBuilder()
|
||||
.setStrictness(Strictness.STRICT)
|
||||
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
|
||||
.disableHtmlEscaping()
|
||||
.create();
|
||||
private static final TypeToken<RpcMessage> MESSAGE_TYPE = TypeToken.get(RpcMessage.class);
|
||||
|
||||
private static final AtomicReference<TheseusRpc> RPC = new AtomicReference<>();
|
||||
|
||||
private final BlockingQueue<RpcMessage> mainThreadQueue = new LinkedBlockingQueue<>();
|
||||
private final Map<UUID, ResponseWaiter<?>> awaitingResponse = new ConcurrentHashMap<>();
|
||||
private final Map<String, Function<JsonElement[], JsonElement>> handlers;
|
||||
private final Socket socket;
|
||||
|
||||
private TheseusRpc(Socket socket, RpcHandlers handlers) {
|
||||
this.socket = socket;
|
||||
this.handlers = handlers.build();
|
||||
}
|
||||
|
||||
public static void connectAndStart(String host, int port, RpcHandlers handlers) throws IOException {
|
||||
if (RPC.get() != null) {
|
||||
throw new IllegalStateException("Can only connect to RPC once");
|
||||
}
|
||||
|
||||
final Socket socket = new Socket(host, port);
|
||||
final TheseusRpc rpc = new TheseusRpc(socket, handlers);
|
||||
final Thread mainThread = new Thread(rpc::mainThread, "Theseus RPC Main");
|
||||
final Thread readThread = new Thread(rpc::readThread, "Theseus RPC Read");
|
||||
mainThread.setDaemon(true);
|
||||
readThread.setDaemon(true);
|
||||
mainThread.start();
|
||||
readThread.start();
|
||||
RPC.set(rpc);
|
||||
}
|
||||
|
||||
public static TheseusRpc getRpc() {
|
||||
final TheseusRpc rpc = RPC.get();
|
||||
if (rpc == null) {
|
||||
throw new IllegalStateException("Called getRpc before RPC initialized");
|
||||
}
|
||||
return rpc;
|
||||
}
|
||||
|
||||
public <T> CompletableFuture<T> callMethod(TypeToken<T> returnType, String method, Object... args) {
|
||||
final JsonElement[] jsonArgs = new JsonElement[args.length];
|
||||
for (int i = 0; i < args.length; i++) {
|
||||
jsonArgs[i] = GSON.toJsonTree(args[i]);
|
||||
}
|
||||
|
||||
final RpcMessage message = new RpcMessage(method, jsonArgs);
|
||||
final ResponseWaiter<T> responseWaiter = new ResponseWaiter<>(returnType);
|
||||
awaitingResponse.put(message.id, responseWaiter);
|
||||
mainThreadQueue.add(message);
|
||||
return responseWaiter.future;
|
||||
}
|
||||
|
||||
private void mainThread() {
|
||||
try {
|
||||
final Writer writer = new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8);
|
||||
while (true) {
|
||||
final RpcMessage message = mainThreadQueue.take();
|
||||
final RpcMessage toSend;
|
||||
if (message.isForSending) {
|
||||
toSend = message;
|
||||
} else {
|
||||
final Function<JsonElement[], JsonElement> handler = handlers.get(message.method);
|
||||
if (handler == null) {
|
||||
System.err.println("Unknown theseus RPC method " + message.method);
|
||||
continue;
|
||||
}
|
||||
RpcMessage response;
|
||||
try {
|
||||
response = new RpcMessage(message.id, handler.apply(message.args));
|
||||
} catch (Exception e) {
|
||||
response = new RpcMessage(message.id, e.toString());
|
||||
}
|
||||
toSend = response;
|
||||
}
|
||||
GSON.toJson(toSend, writer);
|
||||
writer.write('\n');
|
||||
writer.flush();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
} catch (InterruptedException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private void readThread() {
|
||||
try {
|
||||
final BufferedReader reader =
|
||||
new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
|
||||
while (true) {
|
||||
final RpcMessage message = GSON.fromJson(reader.readLine(), MESSAGE_TYPE);
|
||||
if (message.method == null) {
|
||||
final ResponseWaiter<?> waiter = awaitingResponse.get(message.id);
|
||||
if (waiter != null) {
|
||||
handleResponse(waiter, message);
|
||||
}
|
||||
} else {
|
||||
mainThreadQueue.put(message);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
} catch (InterruptedException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private <T> void handleResponse(ResponseWaiter<T> waiter, RpcMessage message) {
|
||||
if (message.error != null) {
|
||||
waiter.future.completeExceptionally(new RpcMethodException(message.error));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
waiter.future.complete(GSON.fromJson(message.response, waiter.type));
|
||||
} catch (JsonSyntaxException e) {
|
||||
waiter.future.completeExceptionally(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static class RpcMessage {
|
||||
final UUID id;
|
||||
final String method; // Optional
|
||||
final JsonElement[] args; // Optional
|
||||
final JsonElement response; // Optional
|
||||
final String error; // Optional
|
||||
final transient boolean isForSending;
|
||||
|
||||
RpcMessage(String method, JsonElement[] args) {
|
||||
id = UUID.randomUUID();
|
||||
this.method = method;
|
||||
this.args = args;
|
||||
response = null;
|
||||
error = null;
|
||||
isForSending = true;
|
||||
}
|
||||
|
||||
RpcMessage(UUID id, JsonElement response) {
|
||||
this.id = id;
|
||||
method = null;
|
||||
args = null;
|
||||
this.response = response;
|
||||
error = null;
|
||||
isForSending = true;
|
||||
}
|
||||
|
||||
RpcMessage(UUID id, String error) {
|
||||
this.id = id;
|
||||
method = null;
|
||||
args = null;
|
||||
response = null;
|
||||
this.error = error;
|
||||
isForSending = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static class ResponseWaiter<T> {
|
||||
final TypeToken<T> type;
|
||||
final CompletableFuture<T> future = new CompletableFuture<>();
|
||||
|
||||
ResponseWaiter(TypeToken<T> type) {
|
||||
this.type = type;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN skipped_update TEXT NULL;
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN pending_update_toast_for_version TEXT NULL;
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN auto_download_updates INT NULL;
|
||||
|
Before Width: | Height: | Size: 7.0 KiB |
BIN
packages/app-lib/src/api/minecraft_skins/assets/test/legacy.png
Normal file
|
After Width: | Height: | Size: 435 B |
|
After Width: | Height: | Size: 1.8 KiB |
BIN
packages/app-lib/src/api/minecraft_skins/assets/test/notch.png
Normal file
|
After Width: | Height: | Size: 409 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 934 B |
|
After Width: | Height: | Size: 1.7 KiB |
@@ -1,12 +1,14 @@
|
||||
//! Miscellaneous PNG utilities for Minecraft skins.
|
||||
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::Engine;
|
||||
use bytemuck::{AnyBitPattern, NoUninit};
|
||||
use bytes::Bytes;
|
||||
use data_url::DataUrl;
|
||||
use futures::{Stream, TryStreamExt, future::Either, stream};
|
||||
use itertools::Itertools;
|
||||
use rgb::Rgba;
|
||||
use tokio_util::{compat::FuturesAsyncReadCompatExt, io::SyncIoBridge};
|
||||
use url::Url;
|
||||
|
||||
@@ -84,10 +86,10 @@ pub fn dimensions(png_data: &[u8]) -> crate::Result<(u32, u32)> {
|
||||
Ok((width, height))
|
||||
}
|
||||
|
||||
/// Normalizes the texture of a Minecraft skin to the modern 64x64 format, handling
|
||||
/// legacy 64x32 skins as the vanilla game client does. This function prioritizes
|
||||
/// PNG encoding speed over compression density, so the resulting textures are better
|
||||
/// suited for display purposes, not persistent storage or transmission.
|
||||
/// Normalizes the texture of a Minecraft skin to the modern 64x64 format, handling legacy 64x32
|
||||
/// skins, doing "Notch transparency hack" and making inner parts opaque as the vanilla game client
|
||||
/// does. This function prioritizes PNG encoding speed over compression density, so the resulting
|
||||
/// textures are better suited for display purposes, not persistent storage or transmission.
|
||||
///
|
||||
/// The normalized, processed is returned texture as a byte array in PNG format.
|
||||
pub async fn normalize_skin_texture(
|
||||
@@ -131,43 +133,30 @@ pub async fn normalize_skin_texture(
|
||||
}
|
||||
|
||||
let is_legacy_skin = png_reader.info().height == 32;
|
||||
|
||||
let mut texture_buf = if is_legacy_skin {
|
||||
// Legacy skins have half the height, so duplicate the rows to
|
||||
// turn them into a 64x64 texture
|
||||
vec![0; png_reader.output_buffer_size() * 2]
|
||||
} else {
|
||||
// Modern skins are left as-is
|
||||
vec![0; png_reader.output_buffer_size()]
|
||||
};
|
||||
|
||||
let texture_buf_color_type = png_reader.output_color_type().0;
|
||||
png_reader.next_frame(&mut texture_buf)?;
|
||||
|
||||
let mut texture_buf =
|
||||
get_skin_texture_buffer(&mut png_reader, is_legacy_skin)?;
|
||||
if is_legacy_skin {
|
||||
convert_legacy_skin_texture(
|
||||
&mut texture_buf,
|
||||
texture_buf_color_type,
|
||||
png_reader.info(),
|
||||
)?;
|
||||
convert_legacy_skin_texture(&mut texture_buf, png_reader.info());
|
||||
do_notch_transparency_hack(&mut texture_buf, png_reader.info());
|
||||
}
|
||||
make_inner_parts_opaque(&mut texture_buf, png_reader.info());
|
||||
|
||||
let mut encoded_png = vec![];
|
||||
|
||||
let mut png_encoder = png::Encoder::new(&mut encoded_png, 64, 64);
|
||||
png_encoder.set_color(texture_buf_color_type);
|
||||
png_encoder.set_color(png::ColorType::Rgba);
|
||||
png_encoder.set_depth(png::BitDepth::Eight);
|
||||
png_encoder.set_filter(png::FilterType::NoFilter);
|
||||
png_encoder.set_compression(png::Compression::Fast);
|
||||
|
||||
// Keeping color space information properly set, to handle the occasional
|
||||
// strange PNG with non-sRGB chromacities and/or different grayscale spaces
|
||||
// strange PNG with non-sRGB chromaticities and/or different grayscale spaces
|
||||
// that keeps most people wondering, is what sets a carefully crafted image
|
||||
// manipulation routine apart :)
|
||||
if let Some(source_chromacities) =
|
||||
if let Some(source_chromaticities) =
|
||||
png_reader.info().source_chromaticities.as_ref().copied()
|
||||
{
|
||||
png_encoder.set_source_chromaticities(source_chromacities);
|
||||
png_encoder.set_source_chromaticities(source_chromaticities);
|
||||
}
|
||||
if let Some(source_gamma) =
|
||||
png_reader.info().source_gamma.as_ref().copied()
|
||||
@@ -178,8 +167,10 @@ pub async fn normalize_skin_texture(
|
||||
png_encoder.set_source_srgb(source_srgb);
|
||||
}
|
||||
|
||||
let png_buf = bytemuck::try_cast_slice(&texture_buf)
|
||||
.map_err(|_| ErrorKind::InvalidPng)?;
|
||||
let mut png_writer = png_encoder.write_header()?;
|
||||
png_writer.write_image_data(&texture_buf)?;
|
||||
png_writer.write_image_data(png_buf)?;
|
||||
png_writer.finish()?;
|
||||
|
||||
Ok(encoded_png.into())
|
||||
@@ -187,16 +178,71 @@ pub async fn normalize_skin_texture(
|
||||
.await?
|
||||
}
|
||||
|
||||
/// Reads a skin texture and returns a 64x64 buffer in RGBA format.
|
||||
fn get_skin_texture_buffer<R: Read>(
|
||||
png_reader: &mut png::Reader<R>,
|
||||
is_legacy_skin: bool,
|
||||
) -> crate::Result<Vec<Rgba<u8>>> {
|
||||
let mut png_buf = if is_legacy_skin {
|
||||
// Legacy skins have half the height, so duplicate the rows to
|
||||
// turn them into a 64x64 texture
|
||||
vec![0; png_reader.output_buffer_size() * 2]
|
||||
} else {
|
||||
// Modern skins are left as-is
|
||||
vec![0; png_reader.output_buffer_size()]
|
||||
};
|
||||
png_reader.next_frame(&mut png_buf)?;
|
||||
|
||||
let mut texture_buf = match png_reader.output_color_type().0 {
|
||||
png::ColorType::Grayscale => png_buf
|
||||
.iter()
|
||||
.map(|&value| Rgba {
|
||||
r: value,
|
||||
g: value,
|
||||
b: value,
|
||||
a: 255,
|
||||
})
|
||||
.collect_vec(),
|
||||
png::ColorType::GrayscaleAlpha => png_buf
|
||||
.chunks_exact(2)
|
||||
.map(|chunk| Rgba {
|
||||
r: chunk[0],
|
||||
g: chunk[0],
|
||||
b: chunk[0],
|
||||
a: chunk[1],
|
||||
})
|
||||
.collect_vec(),
|
||||
png::ColorType::Rgb => png_buf
|
||||
.chunks_exact(3)
|
||||
.map(|chunk| Rgba {
|
||||
r: chunk[0],
|
||||
g: chunk[1],
|
||||
b: chunk[2],
|
||||
a: 255,
|
||||
})
|
||||
.collect_vec(),
|
||||
png::ColorType::Rgba => bytemuck::try_cast_vec(png_buf)
|
||||
.map_err(|_| ErrorKind::InvalidPng)?,
|
||||
_ => Err(ErrorKind::InvalidPng)?, // Cannot happen by PNG spec after transformations
|
||||
};
|
||||
|
||||
// Make the added bottom half of the expanded legacy skin buffer transparent
|
||||
if is_legacy_skin {
|
||||
set_alpha(&mut texture_buf, png_reader.info(), 0, 32, 64, 64, 0);
|
||||
}
|
||||
|
||||
Ok(texture_buf)
|
||||
}
|
||||
|
||||
/// Converts a legacy skin texture (32x64 pixels) within a 64x64 buffer to the
|
||||
/// native 64x64 format used by modern Minecraft clients.
|
||||
///
|
||||
/// See also 25w16a's `SkinTextureDownloader#processLegacySkin` method.
|
||||
#[inline]
|
||||
fn convert_legacy_skin_texture(
|
||||
texture_buf: &mut [u8],
|
||||
texture_color_type: png::ColorType,
|
||||
texture_buf: &mut [Rgba<u8, u8>],
|
||||
texture_info: &png::Info,
|
||||
) -> crate::Result<()> {
|
||||
) {
|
||||
/// The skin faces the game client copies around, in order, when converting a
|
||||
/// legacy skin to the native 64x64 format.
|
||||
const FACE_COPY_PARAMETERS: &[(
|
||||
@@ -222,33 +268,55 @@ fn convert_legacy_skin_texture(
|
||||
];
|
||||
|
||||
for (x, y, off_x, off_y, width, height) in FACE_COPY_PARAMETERS {
|
||||
macro_rules! do_copy {
|
||||
($pixel_type:ty) => {
|
||||
copy_rect_mirror_horizontally::<$pixel_type>(
|
||||
// This cast should never fail because all pixels have a depth of 8 bits
|
||||
// after the transformations applied during decoding
|
||||
::bytemuck::try_cast_slice_mut(texture_buf).map_err(|_| ErrorKind::InvalidPng)?,
|
||||
&texture_info,
|
||||
*x,
|
||||
*y,
|
||||
*off_x,
|
||||
*off_y,
|
||||
*width,
|
||||
*height,
|
||||
)
|
||||
};
|
||||
}
|
||||
copy_rect_mirror_horizontally(
|
||||
texture_buf,
|
||||
texture_info,
|
||||
*x,
|
||||
*y,
|
||||
*off_x,
|
||||
*off_y,
|
||||
*width,
|
||||
*height,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
match texture_color_type.samples() {
|
||||
1 => do_copy!(rgb::Gray<u8>),
|
||||
2 => do_copy!(rgb::GrayAlpha<u8>),
|
||||
3 => do_copy!(rgb::Rgb<u8>),
|
||||
4 => do_copy!(rgb::Rgba<u8>),
|
||||
_ => Err(ErrorKind::InvalidPng)?, // Cannot happen by PNG spec after transformations
|
||||
};
|
||||
/// Makes outer head layer transparent if every pixel has alpha greater or equal to 128.
|
||||
///
|
||||
/// See also 25w16a's `SkinTextureDownloader#doNotchTransparencyHack` method.
|
||||
fn do_notch_transparency_hack(
|
||||
texture_buf: &mut [Rgba<u8, u8>],
|
||||
texture_info: &png::Info,
|
||||
) {
|
||||
// The skin part the game client makes transparent
|
||||
let (x1, y1, x2, y2) = (32, 0, 64, 32);
|
||||
|
||||
for y in y1..y2 {
|
||||
for x in x1..x2 {
|
||||
if texture_buf[x + y * texture_info.width as usize].a < 128 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
set_alpha(texture_buf, texture_info, x1, y1, x2, y2, 0);
|
||||
}
|
||||
|
||||
/// Makes inner parts of a skin texture opaque.
|
||||
///
|
||||
/// See also 25w16a's `SkinTextureDownloader#processLegacySkin` method.
|
||||
#[inline]
|
||||
fn make_inner_parts_opaque(
|
||||
texture_buf: &mut [Rgba<u8, u8>],
|
||||
texture_info: &png::Info,
|
||||
) {
|
||||
/// The skin parts the game client makes opaque.
|
||||
const OPAQUE_PART_PARAMETERS: &[(usize, usize, usize, usize)] =
|
||||
&[(0, 0, 32, 16), (0, 16, 64, 32), (16, 48, 48, 64)];
|
||||
|
||||
for (x1, y1, x2, y2) in OPAQUE_PART_PARAMETERS {
|
||||
set_alpha(texture_buf, texture_info, *x1, *y1, *x2, *y2, 255);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies a `width` pixels wide, `height` pixels tall rectangle of pixels within `texture_buf`
|
||||
@@ -260,8 +328,8 @@ fn convert_legacy_skin_texture(
|
||||
/// boolean, boolean)` method, but with the last two parameters fixed to `true` and `false`,
|
||||
/// respectively.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn copy_rect_mirror_horizontally<PixelType: NoUninit + AnyBitPattern>(
|
||||
texture_buf: &mut [PixelType],
|
||||
fn copy_rect_mirror_horizontally(
|
||||
texture_buf: &mut [Rgba<u8, u8>],
|
||||
texture_info: &png::Info,
|
||||
x: usize,
|
||||
y: usize,
|
||||
@@ -283,18 +351,27 @@ fn copy_rect_mirror_horizontally<PixelType: NoUninit + AnyBitPattern>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets alpha for every pixel of a rectangle within `texture_buf`
|
||||
/// whose top-left corner is at `(x1, y1)` and bottom-right corner is at `(x2 - 1, y2 - 1)`.
|
||||
fn set_alpha(
|
||||
texture_buf: &mut [Rgba<u8, u8>],
|
||||
texture_info: &png::Info,
|
||||
x1: usize,
|
||||
y1: usize,
|
||||
x2: usize,
|
||||
y2: usize,
|
||||
alpha: u8,
|
||||
) {
|
||||
for y in y1..y2 {
|
||||
for x in x1..x2 {
|
||||
texture_buf[x + y * texture_info.width as usize].a = alpha;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[tokio::test]
|
||||
async fn normalize_skin_texture_works() {
|
||||
let legacy_png_data = &include_bytes!("assets/default/MissingNo.png")[..];
|
||||
let expected_normalized_png_data =
|
||||
&include_bytes!("assets/test/MissingNo_normalized.png")[..];
|
||||
|
||||
let normalized_png_data =
|
||||
normalize_skin_texture(&UrlOrBlob::Blob(legacy_png_data.into()))
|
||||
.await
|
||||
.expect("Failed to normalize skin texture");
|
||||
|
||||
let decode_to_pixels = |png_data: &[u8]| {
|
||||
let decoder = png::Decoder::new(png_data);
|
||||
let mut reader = decoder.read_info().expect("Failed to read PNG info");
|
||||
@@ -305,19 +382,55 @@ async fn normalize_skin_texture_works() {
|
||||
(buffer, reader.info().clone())
|
||||
};
|
||||
|
||||
let (normalized_pixels, normalized_info) =
|
||||
decode_to_pixels(&normalized_png_data);
|
||||
let (expected_pixels, expected_info) =
|
||||
decode_to_pixels(expected_normalized_png_data);
|
||||
let test_data = [
|
||||
(
|
||||
"legacy",
|
||||
&include_bytes!("assets/test/legacy.png")[..],
|
||||
&include_bytes!("assets/test/legacy_normalized.png")[..],
|
||||
),
|
||||
(
|
||||
"notch",
|
||||
&include_bytes!("assets/test/notch.png")[..],
|
||||
&include_bytes!("assets/test/notch_normalized.png")[..],
|
||||
),
|
||||
(
|
||||
"transparent",
|
||||
&include_bytes!("assets/test/transparent.png")[..],
|
||||
&include_bytes!("assets/test/transparent_normalized.png")[..],
|
||||
),
|
||||
];
|
||||
|
||||
// Check that dimensions match
|
||||
assert_eq!(normalized_info.width, expected_info.width);
|
||||
assert_eq!(normalized_info.height, expected_info.height);
|
||||
assert_eq!(normalized_info.color_type, expected_info.color_type);
|
||||
for (skin_name, original_png_data, expected_normalized_png_data) in
|
||||
test_data
|
||||
{
|
||||
let normalized_png_data =
|
||||
normalize_skin_texture(&UrlOrBlob::Blob(original_png_data.into()))
|
||||
.await
|
||||
.expect("Failed to normalize skin texture");
|
||||
|
||||
// Check that pixel data matches
|
||||
assert_eq!(
|
||||
normalized_pixels, expected_pixels,
|
||||
"Pixel data doesn't match"
|
||||
);
|
||||
let (normalized_pixels, normalized_info) =
|
||||
decode_to_pixels(&normalized_png_data);
|
||||
let (expected_pixels, expected_info) =
|
||||
decode_to_pixels(expected_normalized_png_data);
|
||||
|
||||
// Check that dimensions match
|
||||
assert_eq!(
|
||||
normalized_info.width, expected_info.width,
|
||||
"Widths don't match for {skin_name}"
|
||||
);
|
||||
assert_eq!(
|
||||
normalized_info.height, expected_info.height,
|
||||
"Heights don't match for {skin_name}"
|
||||
);
|
||||
assert_eq!(
|
||||
normalized_info.color_type, expected_info.color_type,
|
||||
"Color types don't match for {skin_name}"
|
||||
);
|
||||
|
||||
// Check that pixel data matches
|
||||
assert_eq!(
|
||||
normalized_pixels, expected_pixels,
|
||||
"Pixel data doesn't match for {skin_name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ pub mod prelude {
|
||||
jre, metadata, minecraft_auth, mr_auth, pack, process,
|
||||
profile::{self, Profile, create},
|
||||
settings,
|
||||
util::io::{IOError, canonicalize},
|
||||
util::{
|
||||
io::{IOError, canonicalize},
|
||||
network::{is_network_metered, tcp_listen_any_loopback},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::state::{CachedEntry, LinkedData, ProfileInstallStage, SideType};
|
||||
use crate::util::fetch::{fetch, fetch_advanced, write_cached_icon};
|
||||
use crate::util::io;
|
||||
|
||||
use path_util::SafeRelativeUtf8UnixPathBuf;
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -27,7 +28,7 @@ pub struct PackFormat {
|
||||
#[derive(Serialize, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PackFile {
|
||||
pub path: String,
|
||||
pub path: SafeRelativeUtf8UnixPathBuf,
|
||||
pub hashes: HashMap<PackFileHash, String>,
|
||||
pub env: Option<HashMap<EnvType, SideType>>,
|
||||
pub downloads: Vec<String>,
|
||||
|
||||
@@ -18,8 +18,8 @@ use super::install_from::{
|
||||
generate_pack_from_version_id,
|
||||
};
|
||||
use crate::data::ProjectType;
|
||||
use std::io::Cursor;
|
||||
use std::path::{Component, PathBuf};
|
||||
use std::io::{Cursor, ErrorKind};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Install a pack
|
||||
/// Wrapper around install_pack_files that generates a pack creation description, and
|
||||
@@ -169,31 +169,22 @@ pub async fn install_zipped_mrpack_files(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let project_path = project.path.to_string();
|
||||
let path = profile::get_full_path(&profile_path)
|
||||
.await?
|
||||
.join(project.path.as_str());
|
||||
|
||||
let path =
|
||||
std::path::Path::new(&project_path).components().next();
|
||||
if let Some(Component::CurDir | Component::Normal(_)) = path
|
||||
{
|
||||
let path = profile::get_full_path(&profile_path)
|
||||
.await?
|
||||
.join(&project_path);
|
||||
cache_file_hash(
|
||||
file.clone(),
|
||||
&profile_path,
|
||||
project.path.as_str(),
|
||||
project.hashes.get(&PackFileHash::Sha1).map(|x| &**x),
|
||||
ProjectType::get_from_parent_folder(&path),
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
cache_file_hash(
|
||||
file.clone(),
|
||||
&profile_path,
|
||||
&project_path,
|
||||
project
|
||||
.hashes
|
||||
.get(&PackFileHash::Sha1)
|
||||
.map(|x| &**x),
|
||||
ProjectType::get_from_parent_folder(&path),
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
write(&path, &file, &state.io_semaphore).await?;
|
||||
|
||||
write(&path, &file, &state.io_semaphore).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
@@ -377,9 +368,10 @@ pub async fn remove_all_related_files(
|
||||
if let Some(metadata) = &project.metadata
|
||||
&& to_remove.contains(&metadata.project_id)
|
||||
{
|
||||
let path = profile_full_path.join(file_path);
|
||||
if path.exists() {
|
||||
io::remove_file(&path).await?;
|
||||
match io::remove_file(profile_full_path.join(file_path)).await {
|
||||
Ok(_) => (),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => (),
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,9 +379,12 @@ pub async fn remove_all_related_files(
|
||||
// Iterate over all Modrinth project file paths in the json, and remove them
|
||||
// (There should be few, but this removes any files the .mrpack intended as Modrinth projects but were unrecognized)
|
||||
for file in pack.files {
|
||||
let path: PathBuf = profile_full_path.join(file.path);
|
||||
if path.exists() {
|
||||
io::remove_file(&path).await?;
|
||||
match io::remove_file(profile_full_path.join(file.path.as_str()))
|
||||
.await
|
||||
{
|
||||
Ok(_) => (),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => (),
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,11 +407,16 @@ pub async fn remove_all_related_files(
|
||||
}
|
||||
|
||||
// Remove this file if a corresponding one exists in the filesystem
|
||||
let existing_file = profile::get_full_path(&profile_path)
|
||||
.await?
|
||||
.join(&new_path);
|
||||
if existing_file.exists() {
|
||||
io::remove_file(&existing_file).await?;
|
||||
match io::remove_file(
|
||||
profile::get_full_path(&profile_path)
|
||||
.await?
|
||||
.join(&new_path),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => (),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => (),
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::util::io::{self, IOError};
|
||||
pub use crate::{State, state::Profile};
|
||||
use async_zip::tokio::write::ZipFileWriter;
|
||||
use async_zip::{Compression, ZipEntryBuilder};
|
||||
use path_util::SafeRelativeUtf8UnixPathBuf;
|
||||
use serde_json::json;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -497,11 +498,12 @@ pub async fn export_mrpack(
|
||||
let version_id = version_id.unwrap_or("1.0.0".to_string());
|
||||
let mut packfile =
|
||||
create_mrpack_json(&profile, version_id, description).await?;
|
||||
let included_candidates_set =
|
||||
HashSet::<_>::from_iter(included_export_candidates.iter());
|
||||
let included_candidates_set = HashSet::<_>::from_iter(
|
||||
included_export_candidates.iter().map(|x| x.as_str()),
|
||||
);
|
||||
packfile
|
||||
.files
|
||||
.retain(|f| included_candidates_set.contains(&f.path));
|
||||
.retain(|f| included_candidates_set.contains(f.path.as_str()));
|
||||
|
||||
// Build vec of all files in the folder
|
||||
let mut path_list = Vec::new();
|
||||
@@ -575,8 +577,8 @@ pub async fn export_mrpack(
|
||||
#[tracing::instrument]
|
||||
pub async fn get_pack_export_candidates(
|
||||
profile_path: &str,
|
||||
) -> crate::Result<Vec<String>> {
|
||||
let mut path_list: Vec<String> = Vec::new();
|
||||
) -> crate::Result<Vec<SafeRelativeUtf8UnixPathBuf>> {
|
||||
let mut path_list = Vec::new();
|
||||
|
||||
let profile_base_dir = get_full_path(profile_path).await?;
|
||||
let mut read_dir = io::read_dir(&profile_base_dir).await?;
|
||||
@@ -610,18 +612,19 @@ pub async fn get_pack_export_candidates(
|
||||
fn pack_get_relative_path(
|
||||
profile_path: &PathBuf,
|
||||
path: &PathBuf,
|
||||
) -> crate::Result<String> {
|
||||
Ok(path
|
||||
.strip_prefix(profile_path)
|
||||
.map_err(|_| {
|
||||
crate::ErrorKind::FSError(format!(
|
||||
"Path {path:?} does not correspond to a profile"
|
||||
))
|
||||
})?
|
||||
.components()
|
||||
.map(|c| c.as_os_str().to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/"))
|
||||
) -> crate::Result<SafeRelativeUtf8UnixPathBuf> {
|
||||
Ok(SafeRelativeUtf8UnixPathBuf::try_from(
|
||||
path.strip_prefix(profile_path)
|
||||
.map_err(|_| {
|
||||
crate::ErrorKind::FSError(format!(
|
||||
"Path {path:?} does not correspond to a profile"
|
||||
))
|
||||
})?
|
||||
.components()
|
||||
.map(|c| c.as_os_str().to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/"),
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Run Minecraft using a profile and the default credentials, logged in credentials,
|
||||
@@ -896,7 +899,15 @@ pub async fn create_mrpack_json(
|
||||
.collect();
|
||||
|
||||
Some(Ok(PackFile {
|
||||
path,
|
||||
path: match path.try_into() {
|
||||
Ok(path) => path,
|
||||
Err(_) => {
|
||||
return Some(Err(crate::ErrorKind::OtherError(
|
||||
"Invalid file path in project".into(),
|
||||
)
|
||||
.as_error()));
|
||||
}
|
||||
},
|
||||
hashes,
|
||||
env: Some(env),
|
||||
downloads,
|
||||
|
||||
@@ -120,11 +120,11 @@ fn parse_server_address_inner(
|
||||
let mut port = None;
|
||||
if !port_str.is_empty() {
|
||||
if port_str.starts_with('+') {
|
||||
return Err(format!("Unparseable port number: {port_str}"));
|
||||
return Err(format!("Unparsable port number: {port_str}"));
|
||||
}
|
||||
port = port_str.parse::<u16>().ok();
|
||||
if port.is_none() {
|
||||
return Err(format!("Unparseable port number: {port_str}"));
|
||||
return Err(format!("Unparsable port number: {port_str}"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,17 @@ use std::sync::Arc;
|
||||
|
||||
use crate::{profile, util};
|
||||
use data_url::DataUrlError;
|
||||
use derive_more::Display;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing_error::InstrumentError;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Display)]
|
||||
#[display("{description}")]
|
||||
pub struct LabrinthError {
|
||||
pub error: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ErrorKind {
|
||||
#[error("Filesystem error: {0}")]
|
||||
@@ -56,6 +65,9 @@ pub enum ErrorKind {
|
||||
#[error("Error fetching URL: {0}")]
|
||||
FetchError(#[from] reqwest::Error),
|
||||
|
||||
#[error("{0}")]
|
||||
LabrinthError(LabrinthError),
|
||||
|
||||
#[error("Websocket error: {0}")]
|
||||
WSError(#[from] async_tungstenite::tungstenite::Error),
|
||||
|
||||
@@ -186,6 +198,18 @@ pub enum ErrorKind {
|
||||
ParseError {
|
||||
reason: String,
|
||||
},
|
||||
#[error("RPC error: {0}")]
|
||||
RpcError(String),
|
||||
|
||||
#[cfg(windows)]
|
||||
#[error("Windows error: {0}")]
|
||||
WindowsError(#[from] windows_core::Error),
|
||||
|
||||
#[error("zbus error: {0}")]
|
||||
ZbusError(#[from] zbus::Error),
|
||||
|
||||
#[error("Deserialization error: {0}")]
|
||||
DeserializationError(#[from] serde::de::value::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -16,14 +16,14 @@ use uuid::Uuid;
|
||||
const CLI_PROGRESS_BAR_TOTAL: u64 = 1000;
|
||||
|
||||
/*
|
||||
Events are a way we can communciate with the Tauri frontend from the Rust backend.
|
||||
Events are a way we can communicate with the Tauri frontend from the Rust backend.
|
||||
We include a feature flag for Tauri, so that we can compile this code without Tauri.
|
||||
|
||||
To use events, we need to do the following:
|
||||
1) Make sure we are using the tauri feature flag
|
||||
2) Initialize the EventState with EventState::init() *before* initializing the theseus State
|
||||
3) Call emit_x functions to send events to the frontend
|
||||
For emit_loading() specifically, we need to inialize the loading bar with init_loading() first and pass the received loader in
|
||||
For emit_loading() specifically, we need to initialize the loading bar with init_loading() first and pass the received loader in
|
||||
|
||||
For example:
|
||||
pub async fn loading_function() -> crate::Result<()> {
|
||||
@@ -306,7 +306,7 @@ pub async fn emit_friend(payload: FriendPayload) -> crate::Result<()> {
|
||||
// loading_join! macro
|
||||
// loading_join!(key: Option<&LoadingBarId>, total: f64, message: Option<&str>; task1, task2, task3...)
|
||||
// This will submit a loading event with the given message for each task as they complete
|
||||
// task1, task2, task3 are async tasks that yuo want to to join on await on
|
||||
// task1, task2, task3 are async tasks that you want to to join on await on
|
||||
// Key is the key to use for which loading bar to submit these results to- a LoadingBarId. If None, it does nothing
|
||||
// Total is the total amount of progress that the loading bar should take up by all futures in this (will be split evenly amongst them).
|
||||
// If message is Some(t) you will overwrite this loading bar's message with a custom one
|
||||
|
||||
@@ -179,7 +179,6 @@ pub enum LoadingBarType {
|
||||
CurseForgeProfileDownload {
|
||||
profile_name: String,
|
||||
},
|
||||
CheckingForUpdates,
|
||||
LauncherUpdate {
|
||||
version: String,
|
||||
current_version: String,
|
||||
|
||||
@@ -14,8 +14,9 @@ use daedalus::{
|
||||
modded::SidedDataEntry,
|
||||
};
|
||||
use dunce::canonicalize;
|
||||
use hashlink::LinkedHashSet;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use itertools::Itertools;
|
||||
use std::io::{BufRead, BufReader, ErrorKind};
|
||||
use std::net::SocketAddr;
|
||||
use std::{collections::HashMap, path::Path};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -29,9 +30,21 @@ pub fn get_class_paths(
|
||||
java_arch: &str,
|
||||
minecraft_updated: bool,
|
||||
) -> crate::Result<String> {
|
||||
let mut cps = libraries
|
||||
launcher_class_path
|
||||
.iter()
|
||||
.filter_map(|library| {
|
||||
.map(|path| {
|
||||
Ok(canonicalize(path)
|
||||
.map_err(|_| {
|
||||
crate::ErrorKind::LauncherError(format!(
|
||||
"Specified class path {} does not exist",
|
||||
path.to_string_lossy()
|
||||
))
|
||||
.as_error()
|
||||
})?
|
||||
.to_string_lossy()
|
||||
.to_string())
|
||||
})
|
||||
.chain(libraries.iter().filter_map(|library| {
|
||||
if let Some(rules) = &library.rules
|
||||
&& !parse_rules(
|
||||
rules,
|
||||
@@ -47,29 +60,15 @@ pub fn get_class_paths(
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(get_lib_path(libraries_path, &library.name, false))
|
||||
Some(get_lib_path(
|
||||
libraries_path,
|
||||
&library.name,
|
||||
library.natives_os_key_and_classifiers(java_arch).is_some(),
|
||||
))
|
||||
}))
|
||||
.process_results(|iter| {
|
||||
iter.unique().join(classpath_separator(java_arch))
|
||||
})
|
||||
.collect::<Result<LinkedHashSet<_>, _>>()?;
|
||||
|
||||
for launcher_path in launcher_class_path {
|
||||
cps.insert(
|
||||
canonicalize(launcher_path)
|
||||
.map_err(|_| {
|
||||
crate::ErrorKind::LauncherError(format!(
|
||||
"Specified class path {} does not exist",
|
||||
launcher_path.to_string_lossy()
|
||||
))
|
||||
.as_error()
|
||||
})?
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(cps
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>()
|
||||
.join(classpath_separator(java_arch)))
|
||||
}
|
||||
|
||||
pub fn get_class_paths_jar<T: AsRef<str>>(
|
||||
@@ -90,21 +89,21 @@ pub fn get_lib_path(
|
||||
lib: &str,
|
||||
allow_not_exist: bool,
|
||||
) -> crate::Result<String> {
|
||||
let path = libraries_path
|
||||
.to_path_buf()
|
||||
.join(get_path_from_artifact(lib)?);
|
||||
let path = libraries_path.join(get_path_from_artifact(lib)?);
|
||||
|
||||
if !path.exists() && allow_not_exist {
|
||||
return Ok(path.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
let path = &canonicalize(&path).map_err(|_| {
|
||||
crate::ErrorKind::LauncherError(format!(
|
||||
"Library file at path {} does not exist",
|
||||
path.to_string_lossy()
|
||||
))
|
||||
.as_error()
|
||||
})?;
|
||||
let path = match canonicalize(&path) {
|
||||
Ok(p) => p,
|
||||
Err(err) if err.kind() == ErrorKind::NotFound && allow_not_exist => {
|
||||
path
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(crate::ErrorKind::LauncherError(format!(
|
||||
"Could not canonicalize library path {}: {err}",
|
||||
path.display()
|
||||
))
|
||||
.as_error());
|
||||
}
|
||||
};
|
||||
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
}
|
||||
@@ -124,6 +123,7 @@ pub fn get_jvm_arguments(
|
||||
quick_play_type: &QuickPlayType,
|
||||
quick_play_version: QuickPlayVersion,
|
||||
log_config: Option<&LoggingConfiguration>,
|
||||
ipc_addr: SocketAddr,
|
||||
) -> crate::Result<Vec<String>> {
|
||||
let mut parsed_arguments = Vec::new();
|
||||
|
||||
@@ -181,6 +181,11 @@ pub fn get_jvm_arguments(
|
||||
.to_string_lossy()
|
||||
));
|
||||
|
||||
parsed_arguments
|
||||
.push(format!("-Dmodrinth.internal.ipc.host={}", ipc_addr.ip()));
|
||||
parsed_arguments
|
||||
.push(format!("-Dmodrinth.internal.ipc.port={}", ipc_addr.port()));
|
||||
|
||||
parsed_arguments.push(format!(
|
||||
"-Dmodrinth.internal.quickPlay.serverVersion={}",
|
||||
serde_json::to_value(quick_play_version.server)?
|
||||
|
||||
@@ -8,13 +8,13 @@ use crate::{
|
||||
emit::{emit_loading, loading_try_for_each_concurrent},
|
||||
},
|
||||
state::State,
|
||||
util::{fetch::*, io, platform::OsExt},
|
||||
util::{fetch::*, io},
|
||||
};
|
||||
use daedalus::minecraft::{LoggingConfiguration, LoggingSide};
|
||||
use daedalus::{
|
||||
self as d,
|
||||
minecraft::{
|
||||
Asset, AssetsIndex, Library, Os, Version as GameVersion,
|
||||
Asset, AssetsIndex, Library, Version as GameVersion,
|
||||
VersionInfo as GameVersionInfo,
|
||||
},
|
||||
modded::LoaderVersion,
|
||||
@@ -288,90 +288,132 @@ pub async fn download_libraries(
|
||||
}?;
|
||||
let num_files = libraries.len();
|
||||
loading_try_for_each_concurrent(
|
||||
stream::iter(libraries.iter())
|
||||
.map(Ok::<&Library, crate::Error>), None, loading_bar,loading_amount,num_files, None,|library| async move {
|
||||
if let Some(rules) = &library.rules
|
||||
&& !parse_rules(rules, java_arch, &QuickPlayType::None, minecraft_updated) {
|
||||
tracing::trace!("Skipped library {}", &library.name);
|
||||
return Ok(());
|
||||
}
|
||||
stream::iter(libraries.iter()).map(Ok::<&Library, crate::Error>),
|
||||
None,
|
||||
loading_bar,
|
||||
loading_amount,
|
||||
num_files,
|
||||
None,
|
||||
|library| async move {
|
||||
if let Some(rules) = &library.rules
|
||||
&& !parse_rules(
|
||||
rules,
|
||||
java_arch,
|
||||
&QuickPlayType::None,
|
||||
minecraft_updated,
|
||||
)
|
||||
{
|
||||
tracing::trace!("Skipped library {}", &library.name);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !library.downloadable {
|
||||
tracing::trace!("Skipped non-downloadable library {}", &library.name);
|
||||
if !library.downloadable {
|
||||
tracing::trace!(
|
||||
"Skipped non-downloadable library {}",
|
||||
&library.name
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// When a library has natives, we only need to download such natives, as PrismLauncher does
|
||||
if let Some((os_key, classifiers)) =
|
||||
library.natives_os_key_and_classifiers(java_arch)
|
||||
{
|
||||
let parsed_key = os_key
|
||||
.replace("${arch}", crate::util::platform::ARCH_WIDTH);
|
||||
|
||||
if let Some(native) = classifiers.get(&parsed_key) {
|
||||
let data = fetch(
|
||||
&native.url,
|
||||
Some(&native.sha1),
|
||||
&st.fetch_semaphore,
|
||||
&st.pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Ok(mut archive) =
|
||||
zip::ZipArchive::new(std::io::Cursor::new(&data))
|
||||
{
|
||||
match archive.extract(
|
||||
st.directories.version_natives_dir(version),
|
||||
) {
|
||||
Ok(_) => tracing::debug!(
|
||||
"Fetched native {}",
|
||||
&library.name
|
||||
),
|
||||
Err(err) => tracing::error!(
|
||||
"Failed extracting native {}. err: {err}",
|
||||
&library.name
|
||||
),
|
||||
}
|
||||
} else {
|
||||
tracing::error!(
|
||||
"Failed extracting native {}",
|
||||
&library.name
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let artifact_path = d::get_path_from_artifact(&library.name)?;
|
||||
let path = st.directories.libraries_dir().join(&artifact_path);
|
||||
|
||||
if path.exists() && !force {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tokio::try_join! {
|
||||
async {
|
||||
let artifact_path = d::get_path_from_artifact(&library.name)?;
|
||||
let path = st.directories.libraries_dir().join(&artifact_path);
|
||||
if let Some(d::minecraft::LibraryDownloads {
|
||||
artifact: Some(ref artifact),
|
||||
..
|
||||
}) = library.downloads
|
||||
&& !artifact.url.is_empty()
|
||||
{
|
||||
let bytes = fetch(
|
||||
&artifact.url,
|
||||
Some(&artifact.sha1),
|
||||
&st.fetch_semaphore,
|
||||
&st.pool,
|
||||
)
|
||||
.await?;
|
||||
write(&path, &bytes, &st.io_semaphore).await?;
|
||||
|
||||
if path.exists() && !force {
|
||||
return Ok(());
|
||||
}
|
||||
tracing::trace!(
|
||||
"Fetched library {} to path {:?}",
|
||||
&library.name,
|
||||
&path
|
||||
);
|
||||
} else {
|
||||
// We lack an artifact URL, so fall back to constructing one ourselves.
|
||||
// PrismLauncher just ignores the library if this is the case, so it's
|
||||
// probably not needed, but previous code revisions of the Modrinth App
|
||||
// intended to do this, so we keep that behavior for compatibility.
|
||||
|
||||
if let Some(d::minecraft::LibraryDownloads { artifact: Some(ref artifact), ..}) = library.downloads
|
||||
&& !artifact.url.is_empty(){
|
||||
let bytes = fetch(&artifact.url, Some(&artifact.sha1), &st.fetch_semaphore, &st.pool)
|
||||
.await?;
|
||||
write(&path, &bytes, &st.io_semaphore).await?;
|
||||
tracing::trace!("Fetched library {} to path {:?}", &library.name, &path);
|
||||
return Ok::<_, crate::Error>(());
|
||||
}
|
||||
let url = format!(
|
||||
"{}{artifact_path}",
|
||||
library
|
||||
.url
|
||||
.as_deref()
|
||||
.unwrap_or("https://libraries.minecraft.net/")
|
||||
);
|
||||
|
||||
let url = [
|
||||
library
|
||||
.url
|
||||
.as_deref()
|
||||
.unwrap_or("https://libraries.minecraft.net/"),
|
||||
&artifact_path
|
||||
].concat();
|
||||
let bytes =
|
||||
fetch(&url, None, &st.fetch_semaphore, &st.pool)
|
||||
.await?;
|
||||
|
||||
let bytes = fetch(&url, None, &st.fetch_semaphore, &st.pool).await?;
|
||||
write(&path, &bytes, &st.io_semaphore).await?;
|
||||
tracing::trace!("Fetched library {} to path {:?}", &library.name, &path);
|
||||
Ok::<_, crate::Error>(())
|
||||
},
|
||||
async {
|
||||
// HACK: pseudo try block using or else
|
||||
if let Some((os_key, classifiers)) = None.or_else(|| Some((
|
||||
library
|
||||
.natives
|
||||
.as_ref()?
|
||||
.get(&Os::native_arch(java_arch))?,
|
||||
library
|
||||
.downloads
|
||||
.as_ref()?
|
||||
.classifiers
|
||||
.as_ref()?
|
||||
))) {
|
||||
let parsed_key = os_key.replace(
|
||||
"${arch}",
|
||||
crate::util::platform::ARCH_WIDTH,
|
||||
);
|
||||
write(&path, &bytes, &st.io_semaphore).await?;
|
||||
|
||||
if let Some(native) = classifiers.get(&parsed_key) {
|
||||
let data = fetch(&native.url, Some(&native.sha1), &st.fetch_semaphore, &st.pool).await?;
|
||||
let reader = std::io::Cursor::new(&data);
|
||||
if let Ok(mut archive) = zip::ZipArchive::new(reader) {
|
||||
match archive.extract(st.directories.version_natives_dir(version)) {
|
||||
Ok(_) => tracing::debug!("Fetched native {}", &library.name),
|
||||
Err(err) => tracing::error!("Failed extracting native {}. err: {}", &library.name, err)
|
||||
}
|
||||
} else {
|
||||
tracing::error!("Failed extracting native {}", &library.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}?;
|
||||
|
||||
tracing::debug!("Loaded library {}", library.name);
|
||||
Ok(())
|
||||
tracing::trace!(
|
||||
"Fetched library {} to path {:?}",
|
||||
&library.name,
|
||||
&path
|
||||
);
|
||||
}
|
||||
}
|
||||
).await?;
|
||||
|
||||
tracing::debug!("Loaded library {}", library.name);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing::debug!("Done loading libraries!");
|
||||
Ok(())
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::state::{
|
||||
AccountType, Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage,
|
||||
};
|
||||
use crate::util::{io, utils};
|
||||
use crate::util::rpc::RpcServerBuilder;
|
||||
use crate::{State, get_resource_file, process, state as st};
|
||||
use chrono::Utc;
|
||||
use daedalus as d;
|
||||
@@ -23,7 +24,6 @@ use serde::Deserialize;
|
||||
use st::Profile;
|
||||
use std::fmt::Write;
|
||||
use std::path::PathBuf;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
|
||||
mod args;
|
||||
@@ -611,6 +611,8 @@ pub async fn launch_minecraft(
|
||||
let (main_class_keep_alive, main_class_path) =
|
||||
get_resource_file!(env "JAVA_JARS_DIR" / "theseus.jar")?;
|
||||
|
||||
let rpc_server = RpcServerBuilder::new().launch().await?;
|
||||
|
||||
command.args(
|
||||
args::get_jvm_arguments(
|
||||
args.get(&d::minecraft::ArgumentType::Jvm)
|
||||
@@ -636,6 +638,7 @@ pub async fn launch_minecraft(
|
||||
.logging
|
||||
.as_ref()
|
||||
.and_then(|x| x.get(&LoggingSide::Client)),
|
||||
rpc_server.address(),
|
||||
)?
|
||||
.into_iter(),
|
||||
);
|
||||
@@ -800,7 +803,8 @@ pub async fn launch_minecraft(
|
||||
state.directories.profile_logs_dir(&profile.path),
|
||||
version_info.logging.is_some(),
|
||||
main_class_keep_alive,
|
||||
async |process: &ProcessMetadata, stdin| {
|
||||
rpc_server,
|
||||
async |process: &ProcessMetadata, rpc_server| {
|
||||
let process_start_time = process.start_time.to_rfc3339();
|
||||
let profile_created_time = profile.created.to_rfc3339();
|
||||
let profile_modified_time = profile.modified.to_rfc3339();
|
||||
@@ -823,14 +827,11 @@ pub async fn launch_minecraft(
|
||||
let Some(value) = value else {
|
||||
continue;
|
||||
};
|
||||
stdin.write_all(b"property\t").await?;
|
||||
stdin.write_all(key.as_bytes()).await?;
|
||||
stdin.write_u8(b'\t').await?;
|
||||
stdin.write_all(value.as_bytes()).await?;
|
||||
stdin.write_u8(b'\n').await?;
|
||||
rpc_server
|
||||
.call_method_2::<()>("set_system_property", key, value)
|
||||
.await?;
|
||||
}
|
||||
stdin.write_all(b"launch\n").await?;
|
||||
stdin.flush().await?;
|
||||
rpc_server.call_method::<()>("launch").await?;
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
|
||||
@@ -25,3 +25,9 @@ pub use event::{
|
||||
};
|
||||
pub use logger::start_logger;
|
||||
pub use state::State;
|
||||
|
||||
pub const LAUNCHER_USER_AGENT: &str = concat!(
|
||||
"modrinth/theseus/",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" (support@modrinth.com)"
|
||||
);
|
||||
|
||||
@@ -25,12 +25,11 @@ pub fn start_logger() -> Option<()> {
|
||||
.unwrap_or_else(|_| {
|
||||
tracing_subscriber::EnvFilter::new("theseus=info,theseus_gui=info")
|
||||
});
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.with(filter)
|
||||
.with(tracing_error::ErrorLayer::default());
|
||||
tracing::subscriber::set_global_default(subscriber)
|
||||
.expect("setting default subscriber failed");
|
||||
.with(tracing_error::ErrorLayer::default())
|
||||
.init();
|
||||
Some(())
|
||||
}
|
||||
|
||||
@@ -76,7 +75,7 @@ pub fn start_logger() -> Option<()> {
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("theseus=info"));
|
||||
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(file)
|
||||
@@ -84,10 +83,8 @@ pub fn start_logger() -> Option<()> {
|
||||
.with_timer(ChronoLocal::rfc_3339()),
|
||||
)
|
||||
.with(filter)
|
||||
.with(tracing_error::ErrorLayer::default());
|
||||
|
||||
tracing::subscriber::set_global_default(subscriber)
|
||||
.expect("Setting default subscriber failed");
|
||||
.with(tracing_error::ErrorLayer::default())
|
||||
.init();
|
||||
|
||||
Some(())
|
||||
}
|
||||
|
||||
@@ -519,11 +519,14 @@ impl CacheValue {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, PartialEq, Eq, Debug, Copy, Clone)]
|
||||
#[derive(
|
||||
Deserialize, Serialize, PartialEq, Eq, Debug, Copy, Clone, Default,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CacheBehaviour {
|
||||
/// Serve expired data. If fetch fails / launcher is offline, errors are ignored
|
||||
/// and expired data is served
|
||||
#[default]
|
||||
StaleWhileRevalidateSkipOffline,
|
||||
// Serve expired data, revalidate in background
|
||||
StaleWhileRevalidate,
|
||||
@@ -533,12 +536,6 @@ pub enum CacheBehaviour {
|
||||
Bypass,
|
||||
}
|
||||
|
||||
impl Default for CacheBehaviour {
|
||||
fn default() -> Self {
|
||||
Self::StaleWhileRevalidateSkipOffline
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CachedEntry {
|
||||
id: String,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use crate::ErrorKind;
|
||||
use crate::LAUNCHER_USER_AGENT;
|
||||
use crate::data::ModrinthCredentials;
|
||||
use crate::event::FriendPayload;
|
||||
use crate::event::emit::emit_friend;
|
||||
@@ -81,13 +83,9 @@ impl FriendsSocket {
|
||||
)
|
||||
.into_client_request()?;
|
||||
|
||||
let user_agent = format!(
|
||||
"modrinth/theseus/{} (support@modrinth.com)",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
request.headers_mut().insert(
|
||||
"User-Agent",
|
||||
HeaderValue::from_str(&user_agent).unwrap(),
|
||||
HeaderValue::from_str(LAUNCHER_USER_AGENT).unwrap(),
|
||||
);
|
||||
|
||||
let res = connect_async(request).await;
|
||||
@@ -322,7 +320,7 @@ impl FriendsSocket {
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<()> {
|
||||
fetch_advanced(
|
||||
let result = fetch_advanced(
|
||||
Method::POST,
|
||||
&format!("{}friend/{user_id}", env!("MODRINTH_API_URL_V3")),
|
||||
None,
|
||||
@@ -332,7 +330,18 @@ impl FriendsSocket {
|
||||
semaphore,
|
||||
exec,
|
||||
)
|
||||
.await?;
|
||||
.await;
|
||||
|
||||
if let Err(ref e) = result
|
||||
&& let ErrorKind::LabrinthError(e) = &*e.raw
|
||||
&& e.error == "not_found"
|
||||
{
|
||||
return Err(ErrorKind::OtherError(format!(
|
||||
"No user found with username \"{user_id}\""
|
||||
))
|
||||
.into());
|
||||
}
|
||||
result?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ pub async fn init_watcher() -> crate::Result<FileWatcher> {
|
||||
|
||||
tokio::task::spawn(async move {
|
||||
let span = tracing::span!(tracing::Level::INFO, "init_watcher");
|
||||
tracing::info!(parent: &span, "Initting watcher");
|
||||
tracing::info!(parent: &span, "Initing watcher");
|
||||
while let Some(res) = rx.recv().await {
|
||||
let _span = span.enter();
|
||||
|
||||
@@ -170,38 +170,22 @@ pub(crate) async fn watch_profile(
|
||||
let profile_path = dirs.profiles_dir().join(profile_path);
|
||||
|
||||
if profile_path.exists() && profile_path.is_dir() {
|
||||
for sub_path in ProjectType::iterator().map(|x| x.get_folder()).chain([
|
||||
"crash-reports",
|
||||
"saves",
|
||||
"servers.dat",
|
||||
]) {
|
||||
for sub_path in ProjectType::iterator()
|
||||
.map(|x| x.get_folder())
|
||||
.chain(["crash-reports", "saves"])
|
||||
{
|
||||
let full_path = profile_path.join(sub_path);
|
||||
|
||||
if !full_path.exists() && !full_path.is_symlink() {
|
||||
if !sub_path.contains(".") {
|
||||
if let Err(e) =
|
||||
crate::util::io::create_dir_all(&full_path).await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to create directory for watcher {full_path:?}: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if sub_path == "servers.dat" {
|
||||
const EMPTY_NBT: &[u8] = &[
|
||||
10, // Compound tag
|
||||
0, 0, // Empty name
|
||||
0, // End of compound tag
|
||||
];
|
||||
if let Err(e) =
|
||||
crate::util::io::write(&full_path, EMPTY_NBT).await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to create file for watcher {full_path:?}: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if !full_path.exists()
|
||||
&& !full_path.is_symlink()
|
||||
&& !sub_path.contains(".")
|
||||
&& let Err(e) =
|
||||
crate::util::io::create_dir_all(&full_path).await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to create directory for watcher {full_path:?}: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut watcher = watcher.write().await;
|
||||
@@ -215,6 +199,16 @@ pub(crate) async fn watch_profile(
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let mut watcher = watcher.write().await;
|
||||
if let Err(e) = watcher
|
||||
.watcher()
|
||||
.watch(&profile_path, RecursiveMode::NonRecursive)
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to watch root profile directory for watcher {profile_path:?}: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::event::emit::{emit_process, emit_profile};
|
||||
use crate::event::{ProcessPayloadType, ProfilePayloadType};
|
||||
use crate::profile;
|
||||
use crate::util::io::IOError;
|
||||
use crate::util::rpc::RpcServer;
|
||||
use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
|
||||
use dashmap::DashMap;
|
||||
use quick_xml::Reader;
|
||||
@@ -15,7 +16,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::ExitStatus;
|
||||
use tempfile::TempDir;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::{Child, ChildStdin, Command};
|
||||
use tokio::process::{Child, Command};
|
||||
use uuid::Uuid;
|
||||
|
||||
const LAUNCHER_LOG_PATH: &str = "launcher_log.txt";
|
||||
@@ -46,9 +47,10 @@ impl ProcessManager {
|
||||
logs_folder: PathBuf,
|
||||
xml_logging: bool,
|
||||
main_class_keep_alive: TempDir,
|
||||
rpc_server: RpcServer,
|
||||
post_process_init: impl AsyncFnOnce(
|
||||
&ProcessMetadata,
|
||||
&mut ChildStdin,
|
||||
&RpcServer,
|
||||
) -> crate::Result<()>,
|
||||
) -> crate::Result<ProcessMetadata> {
|
||||
mc_command.stdout(std::process::Stdio::piped());
|
||||
@@ -67,14 +69,12 @@ impl ProcessManager {
|
||||
profile_path: profile_path.to_string(),
|
||||
},
|
||||
child: mc_proc,
|
||||
rpc_server,
|
||||
_main_class_keep_alive: main_class_keep_alive,
|
||||
};
|
||||
|
||||
if let Err(e) = post_process_init(
|
||||
&process.metadata,
|
||||
&mut process.child.stdin.as_mut().unwrap(),
|
||||
)
|
||||
.await
|
||||
if let Err(e) =
|
||||
post_process_init(&process.metadata, &process.rpc_server).await
|
||||
{
|
||||
tracing::error!("Failed to run post-process init: {e}");
|
||||
let _ = process.child.kill().await;
|
||||
@@ -165,6 +165,10 @@ impl ProcessManager {
|
||||
self.processes.get(&id).map(|x| x.metadata.clone())
|
||||
}
|
||||
|
||||
pub fn get_rpc(&self, id: Uuid) -> Option<RpcServer> {
|
||||
self.processes.get(&id).map(|x| x.rpc_server.clone())
|
||||
}
|
||||
|
||||
pub fn get_all(&self) -> Vec<ProcessMetadata> {
|
||||
self.processes
|
||||
.iter()
|
||||
@@ -215,6 +219,7 @@ struct Process {
|
||||
metadata: ProcessMetadata,
|
||||
child: Child,
|
||||
_main_class_keep_alive: TempDir,
|
||||
rpc_server: RpcServer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
|
||||
@@ -38,6 +38,10 @@ pub struct Settings {
|
||||
|
||||
pub developer_mode: bool,
|
||||
pub feature_flags: HashMap<FeatureFlag, bool>,
|
||||
|
||||
pub skipped_update: Option<String>,
|
||||
pub pending_update_toast_for_version: Option<String>,
|
||||
pub auto_download_updates: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Eq, Hash, PartialEq)]
|
||||
@@ -63,7 +67,8 @@ impl Settings {
|
||||
json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,
|
||||
mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,
|
||||
hook_pre_launch, hook_wrapper, hook_post_exit,
|
||||
custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar
|
||||
custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,
|
||||
skipped_update, pending_update_toast_for_version, auto_download_updates
|
||||
FROM settings
|
||||
"
|
||||
)
|
||||
@@ -117,6 +122,10 @@ impl Settings {
|
||||
.as_ref()
|
||||
.and_then(|x| serde_json::from_str(x).ok())
|
||||
.unwrap_or_default(),
|
||||
skipped_update: res.skipped_update,
|
||||
pending_update_toast_for_version: res
|
||||
.pending_update_toast_for_version,
|
||||
auto_download_updates: res.auto_download_updates.map(|x| x == 1),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -170,7 +179,11 @@ impl Settings {
|
||||
|
||||
toggle_sidebar = $26,
|
||||
feature_flags = $27,
|
||||
hide_nametag_skins_page = $28
|
||||
hide_nametag_skins_page = $28,
|
||||
|
||||
skipped_update = $29,
|
||||
pending_update_toast_for_version = $30,
|
||||
auto_download_updates = $31
|
||||
",
|
||||
max_concurrent_writes,
|
||||
max_concurrent_downloads,
|
||||
@@ -199,7 +212,10 @@ impl Settings {
|
||||
self.migrated,
|
||||
self.toggle_sidebar,
|
||||
feature_flags,
|
||||
self.hide_nametag_skins_page
|
||||
self.hide_nametag_skins_page,
|
||||
self.skipped_update,
|
||||
self.pending_update_toast_for_version,
|
||||
self.auto_download_updates,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Functions for fetching information from the Internet
|
||||
use super::io::{self, IOError};
|
||||
use crate::ErrorKind;
|
||||
use crate::LAUNCHER_USER_AGENT;
|
||||
use crate::event::LoadingBarId;
|
||||
use crate::event::emit::emit_loading;
|
||||
use bytes::Bytes;
|
||||
@@ -19,11 +21,8 @@ pub struct FetchSemaphore(pub Semaphore);
|
||||
|
||||
pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
let header = reqwest::header::HeaderValue::from_str(&format!(
|
||||
"modrinth/theseus/{} (support@modrinth.com)",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
))
|
||||
.unwrap();
|
||||
let header =
|
||||
reqwest::header::HeaderValue::from_str(LAUNCHER_USER_AGENT).unwrap();
|
||||
headers.insert(reqwest::header::USER_AGENT, header);
|
||||
reqwest::Client::builder()
|
||||
.tcp_keepalive(Some(time::Duration::from_secs(10)))
|
||||
@@ -108,32 +107,31 @@ pub async fn fetch_advanced(
|
||||
|
||||
let result = req.send().await;
|
||||
match result {
|
||||
Ok(x) => {
|
||||
if x.status().is_server_error() {
|
||||
if attempt <= FETCH_ATTEMPTS {
|
||||
continue;
|
||||
} else {
|
||||
return Err(crate::Error::from(
|
||||
crate::ErrorKind::OtherError(
|
||||
"Server error when fetching content"
|
||||
.to_string(),
|
||||
),
|
||||
));
|
||||
Ok(resp) => {
|
||||
if resp.status().is_server_error() && attempt <= FETCH_ATTEMPTS
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if resp.status().is_client_error()
|
||||
|| resp.status().is_server_error()
|
||||
{
|
||||
let backup_error = resp.error_for_status_ref().unwrap_err();
|
||||
if let Ok(error) = resp.json().await {
|
||||
return Err(ErrorKind::LabrinthError(error).into());
|
||||
}
|
||||
return Err(backup_error.into());
|
||||
}
|
||||
|
||||
let bytes = if let Some((bar, total)) = &loading_bar {
|
||||
let length = x.content_length();
|
||||
let length = resp.content_length();
|
||||
if let Some(total_size) = length {
|
||||
use futures::StreamExt;
|
||||
let mut stream = x.bytes_stream();
|
||||
let mut stream = resp.bytes_stream();
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(item) = stream.next().await {
|
||||
let chunk = item.or(Err(
|
||||
crate::error::ErrorKind::NoValueFor(
|
||||
"fetch bytes".to_string(),
|
||||
),
|
||||
))?;
|
||||
let chunk = item.or(Err(ErrorKind::NoValueFor(
|
||||
"fetch bytes".to_string(),
|
||||
)))?;
|
||||
bytes.append(&mut chunk.to_vec());
|
||||
emit_loading(
|
||||
bar,
|
||||
@@ -145,10 +143,10 @@ pub async fn fetch_advanced(
|
||||
|
||||
Ok(bytes::Bytes::from(bytes))
|
||||
} else {
|
||||
x.bytes().await
|
||||
resp.bytes().await
|
||||
}
|
||||
} else {
|
||||
x.bytes().await
|
||||
resp.bytes().await
|
||||
};
|
||||
|
||||
if let Ok(bytes) = bytes {
|
||||
@@ -158,7 +156,7 @@ pub async fn fetch_advanced(
|
||||
if attempt <= FETCH_ATTEMPTS {
|
||||
continue;
|
||||
} else {
|
||||
return Err(crate::ErrorKind::HashError(
|
||||
return Err(ErrorKind::HashError(
|
||||
sha1.to_string(),
|
||||
hash,
|
||||
)
|
||||
@@ -194,10 +192,9 @@ pub async fn fetch_mirrors(
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
) -> crate::Result<Bytes> {
|
||||
if mirrors.is_empty() {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"No mirrors provided!".to_string(),
|
||||
)
|
||||
.into());
|
||||
return Err(
|
||||
ErrorKind::InputError("No mirrors provided!".to_string()).into()
|
||||
);
|
||||
}
|
||||
|
||||
for (index, mirror) in mirrors.iter().enumerate() {
|
||||
@@ -276,8 +273,8 @@ pub async fn write(
|
||||
}
|
||||
|
||||
pub async fn copy(
|
||||
src: impl AsRef<std::path::Path>,
|
||||
dest: impl AsRef<std::path::Path>,
|
||||
src: impl AsRef<Path>,
|
||||
dest: impl AsRef<Path>,
|
||||
semaphore: &IoSemaphore,
|
||||
) -> crate::Result<()> {
|
||||
let src: &Path = src.as_ref();
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// IO error
|
||||
// A wrapper around the tokio IO functions that adds the path to the error message, instead of the uninformative std::io::Error.
|
||||
|
||||
use std::{io::Write, path::Path};
|
||||
use std::{
|
||||
io::{ErrorKind, Write},
|
||||
path::Path,
|
||||
};
|
||||
use tempfile::NamedTempFile;
|
||||
use tokio::task::spawn_blocking;
|
||||
|
||||
@@ -32,6 +35,13 @@ impl IOError {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> ErrorKind {
|
||||
match self {
|
||||
IOError::IOPathError { source, .. } => source.kind(),
|
||||
IOError::IOError(source) => source.kind(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn canonicalize(
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
pub mod fetch;
|
||||
pub mod io;
|
||||
pub mod jre;
|
||||
pub mod network;
|
||||
pub mod platform;
|
||||
pub mod utils; // [AR] Feature
|
||||
pub mod protocol_version;
|
||||
pub mod rpc;
|
||||
pub mod server_ping;
|
||||
|
||||
93
packages/app-lib/src/util/network.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use crate::Result;
|
||||
use std::io;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
pub async fn tcp_listen_any_loopback() -> io::Result<TcpListener> {
|
||||
// IPv4 is tried first for the best compatibility and performance with most systems.
|
||||
// IPv6 is also tried in case IPv4 is not available. Resolving "localhost" is avoided
|
||||
// to prevent failures deriving from improper name resolution setup. Any available
|
||||
// ephemeral port is used to prevent conflicts with other services. This is all as per
|
||||
// RFC 8252's recommendations
|
||||
const ANY_LOOPBACK_SOCKET: &[SocketAddr] = &[
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
|
||||
SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0),
|
||||
];
|
||||
|
||||
TcpListener::bind(ANY_LOOPBACK_SOCKET).await
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub async fn is_network_metered() -> Result<bool> {
|
||||
use windows::Networking::Connectivity::{
|
||||
NetworkCostType, NetworkInformation,
|
||||
};
|
||||
|
||||
let cost_type = NetworkInformation::GetInternetConnectionProfile()?
|
||||
.GetConnectionCost()?
|
||||
.NetworkCostType()?;
|
||||
Ok(matches!(
|
||||
cost_type,
|
||||
NetworkCostType::Fixed | NetworkCostType::Variable
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub async fn is_network_metered() -> Result<bool> {
|
||||
use crate::ErrorKind;
|
||||
use cidre::dispatch::Queue;
|
||||
use cidre::nw::PathMonitor;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::future::FutureExt;
|
||||
|
||||
let (sender, mut receiver) = mpsc::channel(1);
|
||||
|
||||
let queue = Queue::new();
|
||||
let mut monitor = PathMonitor::new();
|
||||
monitor.set_queue(&queue);
|
||||
monitor.set_update_handler(move |path| {
|
||||
let _ = sender.try_send(path.is_constrained() || path.is_expensive());
|
||||
});
|
||||
|
||||
monitor.start();
|
||||
let result = receiver
|
||||
.recv()
|
||||
.timeout(Duration::from_millis(100))
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
monitor.cancel();
|
||||
|
||||
result.ok_or_else(|| {
|
||||
ErrorKind::OtherError(
|
||||
"NWPathMonitor didn't provide an NWPath in time".to_string(),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub async fn is_network_metered() -> Result<bool> {
|
||||
// Thanks to https://github.com/Hakanbaban53/rclone-manager for showing how to do this
|
||||
use zbus::{Connection, Proxy};
|
||||
|
||||
let connection = Connection::system().await?;
|
||||
let proxy = Proxy::new(
|
||||
&connection,
|
||||
"org.freedesktop.NetworkManager",
|
||||
"/org/freedesktop/NetworkManager",
|
||||
"org.freedesktop.NetworkManager",
|
||||
)
|
||||
.await?;
|
||||
let metered = proxy.get_property("Metered").await?;
|
||||
Ok(matches!(metered, 1 | 3))
|
||||
}
|
||||
|
||||
#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))]
|
||||
pub async fn is_network_metered() -> Result<bool> {
|
||||
tracing::warn!(
|
||||
"is_network_metered called on unsupported platform. Assuming unmetered."
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
@@ -1,65 +1,6 @@
|
||||
//! Platform-related code
|
||||
use daedalus::minecraft::{Os, OsRule};
|
||||
|
||||
// OS detection
|
||||
pub trait OsExt {
|
||||
/// Get the OS of the current system
|
||||
fn native() -> Self;
|
||||
|
||||
/// Gets the OS + Arch of the current system
|
||||
fn native_arch(java_arch: &str) -> Self;
|
||||
|
||||
/// Gets the OS from an OS + Arch
|
||||
fn get_os(&self) -> Self;
|
||||
}
|
||||
|
||||
impl OsExt for Os {
|
||||
fn native() -> Self {
|
||||
match std::env::consts::OS {
|
||||
"windows" => Self::Windows,
|
||||
"macos" => Self::Osx,
|
||||
"linux" => Self::Linux,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn native_arch(java_arch: &str) -> Self {
|
||||
if std::env::consts::OS == "windows" {
|
||||
if java_arch == "aarch64" {
|
||||
Os::WindowsArm64
|
||||
} else {
|
||||
Os::Windows
|
||||
}
|
||||
} else if std::env::consts::OS == "linux" {
|
||||
if java_arch == "aarch64" {
|
||||
Os::LinuxArm64
|
||||
} else if java_arch == "arm" {
|
||||
Os::LinuxArm32
|
||||
} else {
|
||||
Os::Linux
|
||||
}
|
||||
} else if std::env::consts::OS == "macos" {
|
||||
if java_arch == "aarch64" {
|
||||
Os::OsxArm64
|
||||
} else {
|
||||
Os::Osx
|
||||
}
|
||||
} else {
|
||||
Os::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
fn get_os(&self) -> Self {
|
||||
match self {
|
||||
Os::OsxArm64 => Os::Osx,
|
||||
Os::LinuxArm32 => Os::Linux,
|
||||
Os::LinuxArm64 => Os::Linux,
|
||||
Os::WindowsArm64 => Os::Windows,
|
||||
_ => self.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bit width
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
pub const ARCH_WIDTH: &str = "64";
|
||||
|
||||
258
packages/app-lib/src/util/rpc.rs
Normal file
@@ -0,0 +1,258 @@
|
||||
use crate::prelude::tcp_listen_any_loopback;
|
||||
use crate::{ErrorKind, Result};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio::task::AbortHandle;
|
||||
use tokio_util::codec::{Decoder, LinesCodec, LinesCodecError};
|
||||
use uuid::Uuid;
|
||||
|
||||
type HandlerFuture = Pin<Box<dyn Send + Future<Output = Result<Value>>>>;
|
||||
type HandlerMethod = Box<dyn Send + Sync + Fn(Vec<Value>) -> HandlerFuture>;
|
||||
type HandlerMap = HashMap<&'static str, HandlerMethod>;
|
||||
type WaitingResponsesMap =
|
||||
Arc<Mutex<HashMap<Uuid, oneshot::Sender<Result<Value>>>>>;
|
||||
|
||||
pub struct RpcServerBuilder {
|
||||
handlers: HandlerMap,
|
||||
}
|
||||
|
||||
impl RpcServerBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
handlers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// We'll use this function in the future. Please remove this #[allow] when we do.
|
||||
#[allow(dead_code)]
|
||||
pub fn handler(
|
||||
mut self,
|
||||
function_name: &'static str,
|
||||
handler: HandlerMethod,
|
||||
) -> Self {
|
||||
self.handlers.insert(function_name, Box::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn launch(self) -> Result<RpcServer> {
|
||||
let socket = tcp_listen_any_loopback().await?;
|
||||
let address = socket.local_addr()?;
|
||||
let (message_sender, message_receiver) = mpsc::unbounded_channel();
|
||||
let waiting_responses = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
let join_handle = {
|
||||
let waiting_responses = waiting_responses.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut server = RunningRpcServer {
|
||||
message_receiver,
|
||||
handlers: self.handlers,
|
||||
waiting_responses: waiting_responses.clone(),
|
||||
};
|
||||
if let Err(e) = server.run(socket).await {
|
||||
tracing::error!("Failed to run RPC server: {e}");
|
||||
}
|
||||
waiting_responses.lock().unwrap().clear();
|
||||
})
|
||||
};
|
||||
Ok(RpcServer {
|
||||
address,
|
||||
message_sender,
|
||||
waiting_responses,
|
||||
abort_handle: join_handle.abort_handle(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RpcServer {
|
||||
address: SocketAddr,
|
||||
message_sender: mpsc::UnboundedSender<RpcMessage>,
|
||||
waiting_responses: WaitingResponsesMap,
|
||||
abort_handle: AbortHandle,
|
||||
}
|
||||
|
||||
impl RpcServer {
|
||||
pub fn address(&self) -> SocketAddr {
|
||||
self.address
|
||||
}
|
||||
|
||||
pub async fn call_method<R: DeserializeOwned>(
|
||||
&self,
|
||||
method: &str,
|
||||
) -> Result<R> {
|
||||
self.call_method_any(method, vec![]).await
|
||||
}
|
||||
|
||||
pub async fn call_method_2<R: DeserializeOwned>(
|
||||
&self,
|
||||
method: &str,
|
||||
arg1: impl Serialize,
|
||||
arg2: impl Serialize,
|
||||
) -> Result<R> {
|
||||
self.call_method_any(
|
||||
method,
|
||||
vec![serde_json::to_value(arg1)?, serde_json::to_value(arg2)?],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn call_method_any<R: DeserializeOwned>(
|
||||
&self,
|
||||
method: &str,
|
||||
args: Vec<Value>,
|
||||
) -> Result<R> {
|
||||
if self.message_sender.is_closed() {
|
||||
return Err(ErrorKind::RpcError(
|
||||
"RPC connection closed".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let (send, recv) = oneshot::channel();
|
||||
self.waiting_responses.lock().unwrap().insert(id, send);
|
||||
|
||||
let message = RpcMessage {
|
||||
id,
|
||||
body: RpcMessageBody::Call {
|
||||
method: method.to_owned(),
|
||||
args,
|
||||
},
|
||||
};
|
||||
if self.message_sender.send(message).is_err() {
|
||||
self.waiting_responses.lock().unwrap().remove(&id);
|
||||
return Err(ErrorKind::RpcError(
|
||||
"RPC connection closed while sending".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
tracing::debug!("Waiting on result for {id}");
|
||||
let Ok(result) = recv.await else {
|
||||
self.waiting_responses.lock().unwrap().remove(&id);
|
||||
return Err(ErrorKind::RpcError(
|
||||
"RPC connection closed while waiting for response".to_string(),
|
||||
)
|
||||
.into());
|
||||
};
|
||||
result.and_then(|x| Ok(serde_json::from_value(x)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RpcServer {
|
||||
fn drop(&mut self) {
|
||||
self.abort_handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
struct RunningRpcServer {
|
||||
message_receiver: mpsc::UnboundedReceiver<RpcMessage>,
|
||||
handlers: HandlerMap,
|
||||
waiting_responses: WaitingResponsesMap,
|
||||
}
|
||||
|
||||
impl RunningRpcServer {
|
||||
async fn run(&mut self, listener: TcpListener) -> Result<()> {
|
||||
let (socket, _) = listener.accept().await?;
|
||||
drop(listener);
|
||||
|
||||
let mut socket = LinesCodec::new().framed(socket);
|
||||
loop {
|
||||
let to_send = tokio::select! {
|
||||
message = self.message_receiver.recv() => {
|
||||
if message.is_none() {
|
||||
break;
|
||||
}
|
||||
message
|
||||
},
|
||||
message = socket.next() => {
|
||||
let message: RpcMessage = match message {
|
||||
None => break,
|
||||
Some(Ok(message)) => serde_json::from_str(&message)?,
|
||||
Some(Err(LinesCodecError::Io(e))) => Err(e)?,
|
||||
Some(Err(LinesCodecError::MaxLineLengthExceeded)) => unreachable!(),
|
||||
};
|
||||
self.handle_message(message).await?
|
||||
},
|
||||
};
|
||||
if let Some(message) = to_send {
|
||||
let json = serde_json::to_string(&message)?;
|
||||
match socket.send(json).await {
|
||||
Ok(()) => {}
|
||||
Err(LinesCodecError::Io(e)) => Err(e)?,
|
||||
Err(LinesCodecError::MaxLineLengthExceeded) => {
|
||||
unreachable!()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_message(
|
||||
&self,
|
||||
message: RpcMessage,
|
||||
) -> Result<Option<RpcMessage>> {
|
||||
if let RpcMessageBody::Call { method, args } = message.body {
|
||||
let response = match self.handlers.get(method.as_str()) {
|
||||
Some(handler) => match handler(args).await {
|
||||
Ok(result) => RpcMessageBody::Respond { response: result },
|
||||
Err(e) => RpcMessageBody::Error {
|
||||
error: e.to_string(),
|
||||
},
|
||||
},
|
||||
None => RpcMessageBody::Error {
|
||||
error: format!("Unknown theseus RPC method {method}"),
|
||||
},
|
||||
};
|
||||
Ok(Some(RpcMessage {
|
||||
id: message.id,
|
||||
body: response,
|
||||
}))
|
||||
} else if let Some(sender) =
|
||||
self.waiting_responses.lock().unwrap().remove(&message.id)
|
||||
{
|
||||
let _ = sender.send(match message.body {
|
||||
RpcMessageBody::Respond { response } => Ok(response),
|
||||
RpcMessageBody::Error { error } => {
|
||||
Err(ErrorKind::RpcError(error).into())
|
||||
}
|
||||
_ => unreachable!(),
|
||||
});
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct RpcMessage {
|
||||
id: Uuid,
|
||||
#[serde(flatten)]
|
||||
body: RpcMessageBody,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RpcMessageBody {
|
||||
Call {
|
||||
method: String,
|
||||
args: Vec<Value>,
|
||||
},
|
||||
Respond {
|
||||
#[serde(default, skip_serializing_if = "Value::is_null")]
|
||||
response: Value,
|
||||
},
|
||||
Error {
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
@@ -4,15 +4,15 @@ version = "0.1.0"
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
uuid = { workspace = true, features = ["v4", "fast-rng", "serde"] }
|
||||
serde_bytes.workspace = true
|
||||
rand.workspace = true
|
||||
either.workspace = true
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
serde_cbor.workspace = true
|
||||
either = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_bytes = { workspace = true }
|
||||
serde_cbor = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true, features = ["fast-rng", "serde", "v4"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="220" height="132" viewBox="0 0 220 132" fill="none">
|
||||
<mask id="path-1-inside-1_687_15946" fill="white">
|
||||
<path
|
||||
d="M204 0C212.837 0 220 7.16344 220 16V140H0V16C0 7.16344 7.16344 0 16 0H204ZM15 12C13.3431 12 12 13.3431 12 15C12 16.6569 13.3431 18 15 18C16.6569 18 18 16.6569 18 15C18 13.3431 16.6569 12 15 12ZM25 12C23.3431 12 22 13.3431 22 15C22 16.6569 23.3431 18 25 18C26.6569 18 28 16.6569 28 15C28 13.3431 26.6569 12 25 12ZM35 12C33.3431 12 32 13.3431 32 15C32 16.6569 33.3431 18 35 18C36.6569 18 38 16.6569 38 15C38 13.3431 36.6569 12 35 12Z" />
|
||||
</mask>
|
||||
<path
|
||||
d="M204 0C212.837 0 220 7.16344 220 16V140H0V16C0 7.16344 7.16344 0 16 0H204ZM15 12C13.3431 12 12 13.3431 12 15C12 16.6569 13.3431 18 15 18C16.6569 18 18 16.6569 18 15C18 13.3431 16.6569 12 15 12ZM25 12C23.3431 12 22 13.3431 22 15C22 16.6569 23.3431 18 25 18C26.6569 18 28 16.6569 28 15C28 13.3431 26.6569 12 25 12ZM35 12C33.3431 12 32 13.3431 32 15C32 16.6569 33.3431 18 35 18C36.6569 18 38 16.6569 38 15C38 13.3431 36.6569 12 35 12Z"
|
||||
fill="white" fill-opacity="0.12" />
|
||||
<path
|
||||
d="M220 16H221H220ZM220 140V141H221V140H220ZM0 140H-1V141H0V140ZM204 0V1C212.284 1 219 7.71573 219 16H220H221C221 6.61116 213.389 -1 204 -1V0ZM220 16H219V140H220H221V16H220ZM220 140V139H0V140V141H220V140ZM0 140H1V16H0H-1V140H0ZM0 16H1C1 7.71573 7.71573 1 16 1V0V-1C6.61116 -1 -1 6.61116 -1 16H0ZM16 0V1H204V0V-1H16V0ZM15 12V11C12.7909 11 11 12.7909 11 15H12H13C13 13.8954 13.8954 13 15 13V12ZM12 15H11C11 17.2091 12.7909 19 15 19V18V17C13.8954 17 13 16.1046 13 15H12ZM15 18V19C17.2091 19 19 17.2091 19 15H18H17C17 16.1046 16.1046 17 15 17V18ZM18 15H19C19 12.7909 17.2091 11 15 11V12V13C16.1046 13 17 13.8954 17 15H18ZM25 12V11C22.7909 11 21 12.7909 21 15H22H23C23 13.8954 23.8954 13 25 13V12ZM22 15H21C21 17.2091 22.7909 19 25 19V18V17C23.8954 17 23 16.1046 23 15H22ZM25 18V19C27.2091 19 29 17.2091 29 15H28H27C27 16.1046 26.1046 17 25 17V18ZM28 15H29C29 12.7909 27.2091 11 25 11V12V13C26.1046 13 27 13.8954 27 15H28ZM35 12V11C32.7909 11 31 12.7909 31 15H32H33C33 13.8954 33.8954 13 35 13V12ZM32 15H31C31 17.2091 32.7909 19 35 19V18V17C33.8954 17 33 16.1046 33 15H32ZM35 18V19C37.2091 19 39 17.2091 39 15H38H37C37 16.1046 36.1046 17 35 17V18ZM38 15H39C39 12.7909 37.2091 11 35 11V12V13C36.1046 13 37 13.8954 37 15H38Z"
|
||||
fill="white" fill-opacity="0.12" mask="url(#path-1-inside-1_687_15946)" />
|
||||
<path
|
||||
d="M220 16H221H220ZM220 140V141H221V140H220ZM0 140H-1V141H0V140ZM204 0V1C212.284 1 219 7.71573 219 16H220H221C221 6.61116 213.389 -1 204 -1V0ZM220 16H219V140H220H221V16H220ZM220 140V139H0V140V141H220V140ZM0 140H1V16H0H-1V140H0ZM0 16H1C1 7.71573 7.71573 1 16 1V0V-1C6.61116 -1 -1 6.61116 -1 16H0ZM16 0V1H204V0V-1H16V0ZM15 12V11C12.7909 11 11 12.7909 11 15H12H13C13 13.8954 13.8954 13 15 13V12ZM12 15H11C11 17.2091 12.7909 19 15 19V18V17C13.8954 17 13 16.1046 13 15H12ZM15 18V19C17.2091 19 19 17.2091 19 15H18H17C17 16.1046 16.1046 17 15 17V18ZM18 15H19C19 12.7909 17.2091 11 15 11V12V13C16.1046 13 17 13.8954 17 15H18ZM25 12V11C22.7909 11 21 12.7909 21 15H22H23C23 13.8954 23.8954 13 25 13V12ZM22 15H21C21 17.2091 22.7909 19 25 19V18V17C23.8954 17 23 16.1046 23 15H22ZM25 18V19C27.2091 19 29 17.2091 29 15H28H27C27 16.1046 26.1046 17 25 17V18ZM28 15H29C29 12.7909 27.2091 11 25 11V12V13C26.1046 13 27 13.8954 27 15H28ZM35 12V11C32.7909 11 31 12.7909 31 15H32H33C33 13.8954 33.8954 13 35 13V12ZM32 15H31C31 17.2091 32.7909 19 35 19V18V17C33.8954 17 33 16.1046 33 15H32ZM35 18V19C37.2091 19 39 17.2091 39 15H38H37C37 16.1046 36.1046 17 35 17V18ZM38 15H39C39 12.7909 37.2091 11 35 11V12V13C36.1046 13 37 13.8954 37 15H38Z"
|
||||
fill="url(#paint0_radial_687_15946)" fill-opacity="0.25" mask="url(#path-1-inside-1_687_15946)" />
|
||||
<path
|
||||
d="M110 42C129.882 42 146 58.1177 146 78C146 97.8823 129.882 114 110 114C90.1177 114 74 97.8823 74 78C74 58.1177 90.1177 42 110 42ZM127.828 63.9219C126.266 62.36 123.734 62.3598 122.172 63.9219L104.375 81.7188L97.8281 75.1719C96.266 73.61 93.7339 73.6098 92.1719 75.1719C90.61 76.7339 90.61 79.2661 92.1719 80.8281L101.547 90.2031C103.109 91.7652 105.641 91.765 107.203 90.2031L127.828 69.5781C129.39 68.016 129.39 65.484 127.828 63.9219Z"
|
||||
fill="white" fill-opacity="0.75" />
|
||||
<defs>
|
||||
<radialGradient id="paint0_radial_687_15946" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="rotate(34.0355) scale(184.025 170.739)">
|
||||
<stop stop-color="white" />
|
||||
<stop offset="0.68" stop-color="white" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -8,31 +8,32 @@ import _ArrowBigUpDashIcon from './icons/arrow-big-up-dash.svg?component'
|
||||
import _AsteriskIcon from './icons/asterisk.svg?component'
|
||||
import _BadgeCheckIcon from './icons/badge-check.svg?component'
|
||||
import _BanIcon from './icons/ban.svg?component'
|
||||
import _BellRingIcon from './icons/bell-ring.svg?component'
|
||||
import _BellIcon from './icons/bell.svg?component'
|
||||
import _BellRingIcon from './icons/bell-ring.svg?component'
|
||||
import _BlocksIcon from './icons/blocks.svg?component'
|
||||
import _BoldIcon from './icons/bold.svg?component'
|
||||
import _BookIcon from './icons/book.svg?component'
|
||||
import _BookOpenIcon from './icons/book-open.svg?component'
|
||||
import _BookTextIcon from './icons/book-text.svg?component'
|
||||
import _BookIcon from './icons/book.svg?component'
|
||||
import _BookmarkIcon from './icons/bookmark.svg?component'
|
||||
import _BotIcon from './icons/bot.svg?component'
|
||||
import _BoxImportIcon from './icons/box-import.svg?component'
|
||||
import _BoxIcon from './icons/box.svg?component'
|
||||
import _BoxImportIcon from './icons/box-import.svg?component'
|
||||
import _BracesIcon from './icons/braces.svg?component'
|
||||
import _BrushCleaningIcon from './icons/brush-cleaning.svg?component'
|
||||
import _CalendarIcon from './icons/calendar.svg?component'
|
||||
import _CardIcon from './icons/card.svg?component'
|
||||
import _ChangeSkinIcon from './icons/change-skin.svg?component'
|
||||
import _ChartIcon from './icons/chart.svg?component'
|
||||
import _CheckIcon from './icons/check.svg?component'
|
||||
import _CheckCheckIcon from './icons/check-check.svg?component'
|
||||
import _CheckCircleIcon from './icons/check-circle.svg?component'
|
||||
import _CheckIcon from './icons/check.svg?component'
|
||||
import _ChevronLeftIcon from './icons/chevron-left.svg?component'
|
||||
import _ChevronRightIcon from './icons/chevron-right.svg?component'
|
||||
import _ClearIcon from './icons/clear.svg?component'
|
||||
import _ClientIcon from './icons/client.svg?component'
|
||||
import _ClipboardCopyIcon from './icons/clipboard-copy.svg?component'
|
||||
import _ClockIcon from './icons/clock.svg?component'
|
||||
import _CloudIcon from './icons/cloud.svg?component'
|
||||
import _CodeIcon from './icons/code.svg?component'
|
||||
import _CoffeeIcon from './icons/coffee.svg?component'
|
||||
@@ -56,13 +57,13 @@ import _EditIcon from './icons/edit.svg?component'
|
||||
import _EllipsisVerticalIcon from './icons/ellipsis-vertical.svg?component'
|
||||
import _ExpandIcon from './icons/expand.svg?component'
|
||||
import _ExternalIcon from './icons/external.svg?component'
|
||||
import _EyeOffIcon from './icons/eye-off.svg?component'
|
||||
import _EyeIcon from './icons/eye.svg?component'
|
||||
import _EyeOffIcon from './icons/eye-off.svg?component'
|
||||
import _FileIcon from './icons/file.svg?component'
|
||||
import _FileArchiveIcon from './icons/file-archive.svg?component'
|
||||
import _FileTextIcon from './icons/file-text.svg?component'
|
||||
import _FileIcon from './icons/file.svg?component'
|
||||
import _FilterXIcon from './icons/filter-x.svg?component'
|
||||
import _FilterIcon from './icons/filter.svg?component'
|
||||
import _FilterXIcon from './icons/filter-x.svg?component'
|
||||
import _FolderArchiveIcon from './icons/folder-archive.svg?component'
|
||||
import _FolderOpenIcon from './icons/folder-open.svg?component'
|
||||
import _FolderSearchIcon from './icons/folder-search.svg?component'
|
||||
@@ -79,8 +80,8 @@ import _HashIcon from './icons/hash.svg?component'
|
||||
import _Heading1Icon from './icons/heading-1.svg?component'
|
||||
import _Heading2Icon from './icons/heading-2.svg?component'
|
||||
import _Heading3Icon from './icons/heading-3.svg?component'
|
||||
import _HeartHandshakeIcon from './icons/heart-handshake.svg?component'
|
||||
import _HeartIcon from './icons/heart.svg?component'
|
||||
import _HeartHandshakeIcon from './icons/heart-handshake.svg?component'
|
||||
import _HistoryIcon from './icons/history.svg?component'
|
||||
import _HomeIcon from './icons/home.svg?component'
|
||||
import _ImageIcon from './icons/image.svg?component'
|
||||
@@ -96,13 +97,13 @@ import _LeftArrowIcon from './icons/left-arrow.svg?component'
|
||||
import _LibraryIcon from './icons/library.svg?component'
|
||||
import _LightBulbIcon from './icons/light-bulb.svg?component'
|
||||
import _LinkIcon from './icons/link.svg?component'
|
||||
import _ListIcon from './icons/list.svg?component'
|
||||
import _ListBulletedIcon from './icons/list-bulleted.svg?component'
|
||||
import _ListEndIcon from './icons/list-end.svg?component'
|
||||
import _ListOrderedIcon from './icons/list-ordered.svg?component'
|
||||
import _ListIcon from './icons/list.svg?component'
|
||||
import _LoaderIcon from './icons/loader.svg?component'
|
||||
import _LockOpenIcon from './icons/lock-open.svg?component'
|
||||
import _LockIcon from './icons/lock.svg?component'
|
||||
import _LockOpenIcon from './icons/lock-open.svg?component'
|
||||
import _LogInIcon from './icons/log-in.svg?component'
|
||||
import _LogOutIcon from './icons/log-out.svg?component'
|
||||
import _MailIcon from './icons/mail.svg?component'
|
||||
@@ -113,8 +114,8 @@ import _MessageIcon from './icons/message.svg?component'
|
||||
import _MicrophoneIcon from './icons/microphone.svg?component'
|
||||
import _MinimizeIcon from './icons/minimize.svg?component'
|
||||
import _MinusIcon from './icons/minus.svg?component'
|
||||
import _MonitorSmartphoneIcon from './icons/monitor-smartphone.svg?component'
|
||||
import _MonitorIcon from './icons/monitor.svg?component'
|
||||
import _MonitorSmartphoneIcon from './icons/monitor-smartphone.svg?component'
|
||||
import _MoonIcon from './icons/moon.svg?component'
|
||||
import _MoreHorizontalIcon from './icons/more-horizontal.svg?component'
|
||||
import _MoreVerticalIcon from './icons/more-vertical.svg?component'
|
||||
@@ -123,22 +124,24 @@ import _NoSignalIcon from './icons/no-signal.svg?component'
|
||||
import _NotepadTextIcon from './icons/notepad-text.svg?component'
|
||||
import _OmorphiaIcon from './icons/omorphia.svg?component'
|
||||
import _OrganizationIcon from './icons/organization.svg?component'
|
||||
import _PackageIcon from './icons/package.svg?component'
|
||||
import _PackageClosedIcon from './icons/package-closed.svg?component'
|
||||
import _PackageOpenIcon from './icons/package-open.svg?component'
|
||||
import _PackageIcon from './icons/package.svg?component'
|
||||
import _PaintbrushIcon from './icons/paintbrush.svg?component'
|
||||
import _PickaxeIcon from './icons/pickaxe.svg?component'
|
||||
import _PlayIcon from './icons/play.svg?component'
|
||||
import _PlugIcon from './icons/plug.svg?component'
|
||||
import _PlusIcon from './icons/plus.svg?component'
|
||||
import _RadioButtonCheckedIcon from './icons/radio-button-checked.svg?component'
|
||||
import _RadioButtonIcon from './icons/radio-button.svg?component'
|
||||
import _RadioButtonCheckedIcon from './icons/radio-button-checked.svg?component'
|
||||
import _ReceiptTextIcon from './icons/receipt-text.svg?component'
|
||||
import _RedoIcon from './icons/redo.svg?component'
|
||||
import _RefreshCwIcon from './icons/refresh-cw.svg?component'
|
||||
import _ReplyIcon from './icons/reply.svg?component'
|
||||
import _ReportIcon from './icons/report.svg?component'
|
||||
import _RestoreIcon from './icons/restore.svg?component'
|
||||
import _RightArrowIcon from './icons/right-arrow.svg?component'
|
||||
import _RocketIcon from './icons/rocket.svg?component'
|
||||
import _RotateClockwiseIcon from './icons/rotate-clockwise.svg?component'
|
||||
import _RotateCounterClockwiseIcon from './icons/rotate-counter-clockwise.svg?component'
|
||||
import _RssIcon from './icons/rss.svg?component'
|
||||
@@ -147,8 +150,8 @@ import _ScaleIcon from './icons/scale.svg?component'
|
||||
import _ScanEyeIcon from './icons/scan-eye.svg?component'
|
||||
import _SearchIcon from './icons/search.svg?component'
|
||||
import _SendIcon from './icons/send.svg?component'
|
||||
import _ServerPlusIcon from './icons/server-plus.svg?component'
|
||||
import _ServerIcon from './icons/server.svg?component'
|
||||
import _ServerPlusIcon from './icons/server-plus.svg?component'
|
||||
import _SettingsIcon from './icons/settings.svg?component'
|
||||
import _ShareIcon from './icons/share.svg?component'
|
||||
import _ShieldIcon from './icons/shield.svg?component'
|
||||
@@ -164,6 +167,7 @@ import _StopCircleIcon from './icons/stop-circle.svg?component'
|
||||
import _StrikethroughIcon from './icons/strikethrough.svg?component'
|
||||
import _SunIcon from './icons/sun.svg?component'
|
||||
import _SunriseIcon from './icons/sunrise.svg?component'
|
||||
import _SupportChatIcon from './icons/support-chat.svg?component'
|
||||
import _TagIcon from './icons/tag.svg?component'
|
||||
import _TagsIcon from './icons/tags.svg?component'
|
||||
import _TerminalSquareIcon from './icons/terminal-square.svg?component'
|
||||
@@ -177,23 +181,23 @@ import _TrashIcon from './icons/trash.svg?component'
|
||||
import _TriangleAlertIcon from './icons/triangle-alert.svg?component'
|
||||
import _UnderlineIcon from './icons/underline.svg?component'
|
||||
import _UndoIcon from './icons/undo.svg?component'
|
||||
import _UnknownDonationIcon from './icons/unknown-donation.svg?component'
|
||||
import _UnknownIcon from './icons/unknown.svg?component'
|
||||
import _UnknownDonationIcon from './icons/unknown-donation.svg?component'
|
||||
import _UnlinkIcon from './icons/unlink.svg?component'
|
||||
import _UnplugIcon from './icons/unplug.svg?component'
|
||||
import _UpdatedIcon from './icons/updated.svg?component'
|
||||
import _UploadIcon from './icons/upload.svg?component'
|
||||
import _UserIcon from './icons/user.svg?component'
|
||||
import _UserPlusIcon from './icons/user-plus.svg?component'
|
||||
import _UserXIcon from './icons/user-x.svg?component'
|
||||
import _UserIcon from './icons/user.svg?component'
|
||||
import _UsersIcon from './icons/users.svg?component'
|
||||
import _VersionIcon from './icons/version.svg?component'
|
||||
import _WikiIcon from './icons/wiki.svg?component'
|
||||
import _WindowIcon from './icons/window.svg?component'
|
||||
import _WorldIcon from './icons/world.svg?component'
|
||||
import _WrenchIcon from './icons/wrench.svg?component'
|
||||
import _XCircleIcon from './icons/x-circle.svg?component'
|
||||
import _XIcon from './icons/x.svg?component'
|
||||
import _XCircleIcon from './icons/x-circle.svg?component'
|
||||
import _ZoomInIcon from './icons/zoom-in.svg?component'
|
||||
import _ZoomOutIcon from './icons/zoom-out.svg?component'
|
||||
|
||||
@@ -229,6 +233,7 @@ export const ChevronRightIcon = _ChevronRightIcon
|
||||
export const ClearIcon = _ClearIcon
|
||||
export const ClientIcon = _ClientIcon
|
||||
export const ClipboardCopyIcon = _ClipboardCopyIcon
|
||||
export const ClockIcon = _ClockIcon
|
||||
export const CloudIcon = _CloudIcon
|
||||
export const CodeIcon = _CodeIcon
|
||||
export const CoffeeIcon = _CoffeeIcon
|
||||
@@ -331,10 +336,12 @@ export const RadioButtonCheckedIcon = _RadioButtonCheckedIcon
|
||||
export const RadioButtonIcon = _RadioButtonIcon
|
||||
export const ReceiptTextIcon = _ReceiptTextIcon
|
||||
export const RedoIcon = _RedoIcon
|
||||
export const RefreshCwIcon = _RefreshCwIcon
|
||||
export const ReplyIcon = _ReplyIcon
|
||||
export const ReportIcon = _ReportIcon
|
||||
export const RestoreIcon = _RestoreIcon
|
||||
export const RightArrowIcon = _RightArrowIcon
|
||||
export const RocketIcon = _RocketIcon
|
||||
export const RotateClockwiseIcon = _RotateClockwiseIcon
|
||||
export const RotateCounterClockwiseIcon = _RotateCounterClockwiseIcon
|
||||
export const RssIcon = _RssIcon
|
||||
@@ -360,6 +367,7 @@ export const StopCircleIcon = _StopCircleIcon
|
||||
export const StrikethroughIcon = _StrikethroughIcon
|
||||
export const SunIcon = _SunIcon
|
||||
export const SunriseIcon = _SunriseIcon
|
||||
export const SupportChatIcon = _SupportChatIcon
|
||||
export const TagIcon = _TagIcon
|
||||
export const TagsIcon = _TagsIcon
|
||||
export const TerminalSquareIcon = _TerminalSquareIcon
|
||||
|
||||
1
packages/assets/icons/clock.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-clock-icon lucide-clock"><path d="M12 6v6l4 2"/><circle cx="12" cy="12" r="10"/></svg>
|
||||
|
After Width: | Height: | Size: 288 B |
1
packages/assets/icons/refresh-cw.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-refresh-cw-icon lucide-refresh-cw"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
|
||||
|
After Width: | Height: | Size: 411 B |
1
packages/assets/icons/rocket.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rocket-icon lucide-rocket"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg>
|
||||
|
After Width: | Height: | Size: 544 B |
18
packages/assets/icons/support-chat.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-message-circle-question-mark-icon lucide-message-circle-question-mark"
|
||||
>
|
||||
<path
|
||||
d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"
|
||||
/>
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
||||
<path d="M12 17h.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 509 B |
@@ -10,6 +10,7 @@ import './omorphia.scss'
|
||||
|
||||
import _FourOhFourNotFound from './branding/404.svg?component'
|
||||
// Branding
|
||||
import _BrowserWindowSuccessIllustration from './branding/illustrations/browser-window-success.svg?component'
|
||||
import _ModrinthIcon from './branding/logo.svg?component'
|
||||
import _ModrinthPlusIcon from './branding/modrinth-plus.svg?component'
|
||||
import _AngryRinthbot from './branding/rinthbot/angry.webp'
|
||||
@@ -47,6 +48,7 @@ import _WindowsIcon from './external/windows.svg?component'
|
||||
import _YouTubeIcon from './external/youtube.svg?component'
|
||||
|
||||
export const ModrinthIcon = _ModrinthIcon
|
||||
export const BrowserWindowSuccessIllustration = _BrowserWindowSuccessIllustration
|
||||
export const FourOhFourNotFound = _FourOhFourNotFound
|
||||
export const ModrinthPlusIcon = _ModrinthPlusIcon
|
||||
export const AngryRinthbot = _AngryRinthbot
|
||||
@@ -82,16 +84,12 @@ export const TwitterIcon = _TwitterIcon
|
||||
export const WindowsIcon = _WindowsIcon
|
||||
export const YouTubeIcon = _YouTubeIcon
|
||||
|
||||
// [AR] Feature. Icons
|
||||
|
||||
// [AR] Styles
|
||||
import _PirateIcon from './icons/pirate.svg?component'
|
||||
import _MicrosoftIcon from './icons/microsoft.svg?component'
|
||||
import _PirateShipIcon from './icons/pirate-ship.svg?component'
|
||||
import _AstralRinthLogo from './icons/astralrinth-logo.svg?component'
|
||||
import _ElyByIcon from './icons/elyby-icon.svg?component'
|
||||
|
||||
// [AR] Feature. Exports
|
||||
|
||||
export const PirateIcon = _PirateIcon
|
||||
export const MicrosoftIcon = _MicrosoftIcon
|
||||
export const PirateShipIcon = _PirateShipIcon
|
||||
|
||||
@@ -1,26 +1,110 @@
|
||||
.light-properties {
|
||||
--color-bg: #e5e7eb;
|
||||
--color-raised-bg: #ffffff;
|
||||
--color-super-raised-bg: #e9e9e9;
|
||||
--color-button-bg: hsl(220, 13%, 91%);
|
||||
--surface-1: #ebebeb;
|
||||
--surface-2: #f5f5f5;
|
||||
--surface-3: #f8f8f8;
|
||||
--surface-4: #ffffff;
|
||||
--surface-5: #e6e6e6;
|
||||
|
||||
--color-red-50: #fef2f2;
|
||||
--color-red-100: #fee5e7;
|
||||
--color-red-200: #fccfd3;
|
||||
--color-red-300: #faa7b1;
|
||||
--color-red-400: #f67687;
|
||||
--color-red-500: #ed4661;
|
||||
--color-red-600: #cb2245;
|
||||
--color-red-700: #b7193d;
|
||||
--color-red-800: #9a1739;
|
||||
--color-red-900: #841738;
|
||||
--color-red-950: #490819;
|
||||
--color-red: var(--color-red-600);
|
||||
|
||||
--color-orange-50: #fdf8ed;
|
||||
--color-orange-100: #f9eacc;
|
||||
--color-orange-200: #f2d495;
|
||||
--color-orange-300: #ecb85d;
|
||||
--color-orange-400: #e7a038;
|
||||
--color-orange-500: #e08325;
|
||||
--color-orange-600: #c66019;
|
||||
--color-orange-700: #a44419;
|
||||
--color-orange-800: #86351a;
|
||||
--color-orange-900: #6e2d19;
|
||||
--color-orange-950: #3f1509;
|
||||
--color-orange: var(--color-orange-500);
|
||||
|
||||
--color-green-50: #eefff6;
|
||||
--color-green-100: #d7ffeb;
|
||||
--color-green-200: #b2ffd9;
|
||||
--color-green-300: #76ffbc;
|
||||
--color-green-400: #33f598;
|
||||
--color-green-500: #09de78;
|
||||
--color-green-600: #00af5c;
|
||||
--color-green-700: #04914f;
|
||||
--color-green-800: #0a7141;
|
||||
--color-green-900: #0a5d38;
|
||||
--color-green-950: #00341d;
|
||||
--color-green: var(--color-green-600);
|
||||
|
||||
--color-blue-50: #f2f7fd;
|
||||
--color-blue-100: #e4ecfa;
|
||||
--color-blue-200: #c3d9f4;
|
||||
--color-blue-300: #8ebaeb;
|
||||
--color-blue-400: #5196df;
|
||||
--color-blue-500: #2b79cc;
|
||||
--color-blue-600: #1f68c0;
|
||||
--color-blue-700: #184b8c;
|
||||
--color-blue-800: #184174;
|
||||
--color-blue-900: #193861;
|
||||
--color-blue-950: #112340;
|
||||
--color-blue: var(--color-blue-600);
|
||||
|
||||
--color-purple-50: #faf5ff;
|
||||
--color-purple-100: #f2e7ff;
|
||||
--color-purple-200: #e7d3ff;
|
||||
--color-purple-300: #d4b1ff;
|
||||
--color-purple-400: #ba7eff;
|
||||
--color-purple-500: #9f4dff;
|
||||
--color-purple-600: #8e32f3;
|
||||
--color-purple-700: #761ad6;
|
||||
--color-purple-800: #651bae;
|
||||
--color-purple-900: #53178c;
|
||||
--color-purple-950: #370368;
|
||||
--color-purple: var(--color-purple-600);
|
||||
|
||||
--color-gray-50: #f5f5f6;
|
||||
--color-gray-100: #e5e5e8;
|
||||
--color-gray-200: #cecfd3;
|
||||
--color-gray-300: #adb0b3;
|
||||
--color-gray-400: #83868d;
|
||||
--color-gray-500: #686a72;
|
||||
--color-gray-600: #595b61;
|
||||
--color-gray-700: #4c4e52;
|
||||
--color-gray-800: #434447;
|
||||
--color-gray-900: #3b3b3e;
|
||||
--color-gray-950: #252627;
|
||||
--color-gray: var(--color-gray-600);
|
||||
|
||||
--color-text-primary: #1a202c;
|
||||
--color-text-default: #2c2e31;
|
||||
--color-text-tertiary: #484d54;
|
||||
|
||||
// ===
|
||||
|
||||
--color-bg: var(--surface-1);
|
||||
--color-raised-bg: var(--surface-3);
|
||||
--color-super-raised-bg: var(--surface-4);
|
||||
|
||||
--color-button-bg: var(--surface-4);
|
||||
--color-button-border: rgba(161, 161, 161, 0.35);
|
||||
--color-scrollbar: #96a2b0;
|
||||
|
||||
--color-divider: #babfc5;
|
||||
--color-divider: var(--surface-2);
|
||||
--color-divider-dark: #c8cdd3;
|
||||
|
||||
--color-base: hsl(221, 39%, 11%);
|
||||
--color-secondary: #6b7280;
|
||||
--color-contrast: #1a202c;
|
||||
--color-base: var(--color-text-default);
|
||||
--color-secondary: var(--color-text-tertiary);
|
||||
--color-contrast: var(--color-text-primary);
|
||||
--color-accent-contrast: #ffffff;
|
||||
|
||||
--color-red: #cb2245;
|
||||
--color-orange: #e08325;
|
||||
--color-green: #00af5c;
|
||||
--color-blue: #1f68c0;
|
||||
--color-purple: #8e32f3;
|
||||
--color-gray: #595b61;
|
||||
|
||||
--color-red-highlight: rgba(203, 34, 69, 0.25);
|
||||
--color-orange-highlight: rgba(224, 131, 37, 0.25);
|
||||
--color-green-highlight: rgba(0, 175, 92, 0.25);
|
||||
@@ -119,6 +203,10 @@ html {
|
||||
--color-ad-raised: rgba(190, 140, 243, 0.5);
|
||||
--color-ad-contrast: black;
|
||||
--color-ad-highlight: var(--color-purple);
|
||||
|
||||
--color-link: var(--color-blue) !important;
|
||||
--color-link-hover: var(--color-blue) !important; // DEPRECATED, use filters in future
|
||||
--color-link-active: var(--color-blue) !important; // DEPRECATED, use filters in future
|
||||
}
|
||||
|
||||
.light-mode,
|
||||
@@ -129,28 +217,112 @@ html {
|
||||
.dark-mode,
|
||||
.dark,
|
||||
:root[data-theme='dark'] {
|
||||
--color-bg: #16181c;
|
||||
--color-raised-bg: #26292f;
|
||||
--color-super-raised-bg: #40434a;
|
||||
--color-button-bg: hsl(222, 13%, 30%);
|
||||
--surface-1: #16181c;
|
||||
--surface-2: #1d1f23;
|
||||
--surface-3: #27292e;
|
||||
--surface-4: #34363c;
|
||||
--surface-5: #42444a;
|
||||
|
||||
--color-red-50: #fff0f1;
|
||||
--color-red-100: #ffe2e6;
|
||||
--color-red-200: #ffcad3;
|
||||
--color-red-300: #ff9fae;
|
||||
--color-red-400: #ff6984;
|
||||
--color-red-500: #ff496e;
|
||||
--color-red-600: #ed1148;
|
||||
--color-red-700: #c8083d;
|
||||
--color-red-800: #a8093a;
|
||||
--color-red-900: #8f0c38;
|
||||
--color-red-950: #50011a;
|
||||
--color-red: var(--color-red-500);
|
||||
|
||||
--color-orange-50: #fff8ed;
|
||||
--color-orange-100: #ffefd4;
|
||||
--color-orange-200: #ffdba8;
|
||||
--color-orange-300: #ffc171;
|
||||
--color-orange-400: #ffa347;
|
||||
--color-orange-500: #fe7e11;
|
||||
--color-orange-600: #ef6307;
|
||||
--color-orange-700: #c64808;
|
||||
--color-orange-800: #9d3a0f;
|
||||
--color-orange-900: #7e3110;
|
||||
--color-orange-950: #441606;
|
||||
--color-orange: var(--color-orange-400);
|
||||
|
||||
--color-green-50: #effef5;
|
||||
--color-green-100: #dafee8;
|
||||
--color-green-200: #b8fad2;
|
||||
--color-green-300: #81f4af;
|
||||
--color-green-400: #42e686;
|
||||
--color-green-500: #1bd96a;
|
||||
--color-green-600: #0faa4f;
|
||||
--color-green-700: #0f8642;
|
||||
--color-green-800: #126937;
|
||||
--color-green-900: #11562f;
|
||||
--color-green-950: #033018;
|
||||
--color-green: var(--color-green-500);
|
||||
|
||||
--color-blue-50: #eef6ff;
|
||||
--color-blue-100: #daeaff;
|
||||
--color-blue-200: #bddaff;
|
||||
--color-blue-300: #90c4ff;
|
||||
--color-blue-400: #4f9cff;
|
||||
--color-blue-500: #357ffc;
|
||||
--color-blue-600: #1f5ff1;
|
||||
--color-blue-700: #1749de;
|
||||
--color-blue-800: #193cb4;
|
||||
--color-blue-900: #1a378e;
|
||||
--color-blue-950: #152356;
|
||||
--color-blue: var(--color-blue-400);
|
||||
|
||||
--color-purple-50: #faf5ff;
|
||||
--color-purple-100: #f4e7ff;
|
||||
--color-purple-200: #ead4ff;
|
||||
--color-purple-300: #dab2ff;
|
||||
--color-purple-400: #c78aff;
|
||||
--color-purple-500: #ac51fb;
|
||||
--color-purple-600: #972eef;
|
||||
--color-purple-700: #821ed2;
|
||||
--color-purple-800: #6e1eab;
|
||||
--color-purple-900: #5a198a;
|
||||
--color-purple-950: #3d0566;
|
||||
--color-purple: var(--color-purple-400);
|
||||
|
||||
--color-gray-50: #f5f7f8;
|
||||
--color-gray-100: #edeff2;
|
||||
--color-gray-200: #dfe2e6;
|
||||
--color-gray-300: #cad0d7;
|
||||
--color-gray-400: #b4bac5;
|
||||
--color-gray-500: #9fa4b3;
|
||||
--color-gray-600: #8a8da1;
|
||||
--color-gray-700: #777b8b;
|
||||
--color-gray-800: #616472;
|
||||
--color-gray-900: #52555d;
|
||||
--color-gray-950: #303136;
|
||||
--color-gray: var(--color-gray-500);
|
||||
|
||||
--color-text-primary: #ffffff;
|
||||
--color-text-default: #b0bac5;
|
||||
--color-text-tertiary: #80878f;
|
||||
|
||||
// ===
|
||||
|
||||
--color-bg: var(--surface-1);
|
||||
--color-raised-bg: var(--surface-3);
|
||||
--color-super-raised-bg: var(--surface-4);
|
||||
|
||||
--color-button-bg: var(--surface-4);
|
||||
--color-button-border: rgba(193, 190, 209, 0.12);
|
||||
--color-scrollbar: var(--color-button-bg);
|
||||
|
||||
--color-divider: var(--color-button-bg);
|
||||
--color-divider-dark: #646c75;
|
||||
|
||||
--color-base: var(--dark-color-base);
|
||||
--color-secondary: #96a2b0;
|
||||
--color-contrast: var(--dark-color-contrast);
|
||||
--color-base: var(--color-text-default);
|
||||
--color-secondary: var(--color-text-tertiary);
|
||||
--color-contrast: var(--color-text-primary);
|
||||
--color-accent-contrast: #000000;
|
||||
|
||||
--color-red: #ff496e;
|
||||
--color-orange: #ffa347;
|
||||
--color-green: #1bd96a;
|
||||
--color-blue: #4f9cff;
|
||||
--color-purple: #c78aff;
|
||||
--color-gray: #9fa4b3;
|
||||
|
||||
--color-red-highlight: rgba(255, 73, 110, 0.25);
|
||||
--color-orange-highlight: rgba(255, 163, 71, 0.25);
|
||||
--color-green-highlight: rgba(27, 217, 106, 0.25);
|
||||
@@ -212,15 +384,15 @@ html {
|
||||
--color-platform-nilloader: #f45e9a;
|
||||
|
||||
--hover-brightness: 1.25;
|
||||
|
||||
--experimental-color-button-bg: #33363d;
|
||||
}
|
||||
|
||||
.oled-mode {
|
||||
@extend .dark-mode;
|
||||
--color-bg: #000000;
|
||||
--color-raised-bg: #101013;
|
||||
--color-button-bg: #222329;
|
||||
--surface-1: #000000;
|
||||
--surface-2: #101013;
|
||||
--surface-3: #1b1b20;
|
||||
--surface-4: #25262b;
|
||||
--surface-5: #2e2f34;
|
||||
|
||||
--color-ad: #0d1828;
|
||||
|
||||
@@ -241,52 +413,3 @@ html {
|
||||
.retro-mode {
|
||||
--brand-gradient-strong-bg: #3a3b38;
|
||||
}
|
||||
|
||||
.experimental-styles-within {
|
||||
--color-link: var(--color-blue) !important;
|
||||
--color-link-hover: var(--color-blue) !important; // DEPRECATED, use filters in future
|
||||
--color-link-active: var(--color-blue) !important; // DEPRECATED, use filters in future
|
||||
}
|
||||
|
||||
.light-experiments {
|
||||
--color-bg: #ebebeb;
|
||||
--color-raised-bg: #ffffff;
|
||||
--color-button-bg: #f5f5f5;
|
||||
--color-base: #2c2e31;
|
||||
--color-secondary: #484d54;
|
||||
--color-accent-contrast: #ffffff;
|
||||
}
|
||||
|
||||
.light-mode,
|
||||
.light {
|
||||
.experimental-styles-within,
|
||||
&.experimental-styles-within {
|
||||
@extend .light-experiments;
|
||||
}
|
||||
}
|
||||
|
||||
.experimental-styles-within {
|
||||
.light-mode,
|
||||
.light {
|
||||
@extend .light-experiments;
|
||||
}
|
||||
}
|
||||
|
||||
.dark-experiments {
|
||||
--color-button-bg: var(--experimental-color-button-bg);
|
||||
}
|
||||
|
||||
.dark-mode:not(.oled-mode),
|
||||
.dark:not(.oled-mode) {
|
||||
.experimental-styles-within,
|
||||
&.experimental-styles-within {
|
||||
@extend .dark-experiments;
|
||||
}
|
||||
}
|
||||
|
||||
.experimental-styles-within {
|
||||
.dark-mode:not(.oled-mode),
|
||||
.dark:not(.oled-mode) {
|
||||
@extend .dark-experiments;
|
||||
}
|
||||
}
|
||||
|
||||
20
packages/blog/articles/free-server-medal.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
title: Get a Free Modrinth Server
|
||||
summary: In partnership with Medal.tv, get a 5-day free trial for Modrinth Servers
|
||||
date: 2025-08-20T15:25:00-07:00
|
||||
authors: ['AJfd8YH6']
|
||||
---
|
||||
|
||||
We’re excited to announce we’ve teamed up with [Medal.tv](http://medal.tv/), where millions of gamers share their favorite moments. Starting today, Modrinth users can claim a **free 3GB US server for 5 days** by completing a Medal quest!
|
||||
|
||||
It’s the perfect way to try out Modrinth Servers, completely on us.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. [Download and install Medal](https://quests.medal.tv/modrinth)
|
||||
2. Start the Modrinth Quest
|
||||
3. Save a clip playing your favourite Mod
|
||||
4. Claim your reward
|
||||
5. Manage your server on Modrinth
|
||||
|
||||
No payment required. Just clip, claim, and start playing your favourite mods with your friends! Learn more about how it works [here](https://quests.medal.tv/modrinth).
|
||||
22
packages/blog/articles/modrinth-servers-asia.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
title: Modrinth Servers Launches in Asia
|
||||
summary: Our latest Modrinth Servers datacenter is in Singapore.
|
||||
date: 2025-09-08T14:45:00-07:00
|
||||
authors: ['AJfd8YH6', 'Dc7EYhxG']
|
||||
---
|
||||
|
||||
We're happy to announce that we have just launched [Modrinth Servers](https://modrinth.gg) in one of the most highly anticipated regions: Southeast Asia.
|
||||
|
||||
### What does this mean for me?
|
||||
|
||||
- Lower latency and smoother gameplay for players across Asia and nearby regions.
|
||||
- More choice when creating new servers — Singapore is available as a region starting today.
|
||||
- Room to grow as we continue rolling out infrastructure where you need it most.
|
||||
|
||||
This launch is a big step in bringing Modrinth Servers closer to more of our community. And we’re just getting started.
|
||||
|
||||
In the next few months, we hope to unveil some exciting new changes to Modrinth Servers that will fundamentally change how you host Minecraft servers. Stay tuned and thank you all for your support since we launched 10 months ago!
|
||||
|
||||
<strong data-contrast-text>Host your next server with [Modrinth Servers](https://modrinth.gg) today!</strong>
|
||||
|
||||
What region should be next? [Let us know here](https://surveys.modrinth.com/servers-region-waitlist).
|
||||
80
packages/blog/articles/new-environments.md
Normal file
@@ -0,0 +1,80 @@
|
||||
---
|
||||
title: 'Creators: Verify Your Environment Metadata'
|
||||
summary: We've overhauled the environment metadata on Modrinth, and all creators must verify their settings.
|
||||
date: 2025-08-28T16:50:00-07:00
|
||||
authors: ['Dc7EYhxG']
|
||||
---
|
||||
|
||||
**Hey creators!**
|
||||
|
||||
Over the years, we've taken in lots of feedback regarding how we identify client-side and server-side mods and modpacks on Modrinth. It's a surprisingly nuanced issue, and careful consideration has finally led us to implementing a new system that fixes many of the issues with the old one.
|
||||
|
||||
## What do I need to do?
|
||||
|
||||
If you want to jump right into what you need to do now, just visit your [Projects page](/dashboard/projects) and look for any of your mod or modpack projects with an orange warning button next to the settings button. This will take you to the new Environments page in your project's settings with a bunch of different options for configuring your project's environment.
|
||||
|
||||

|
||||
|
||||
**If you do not verify your environments, in the future a warning may display on your project to inform users that the environment information may be outdated or incorrect.**
|
||||
|
||||
Read on to learn more about why we've made this change and for a more thorough explanation of each option, in case you find any of them confusing.
|
||||
|
||||
## What was wrong with the old system?
|
||||
|
||||
Originally, Modrinth's environment metadata came in the form of two fields for Client-side and Server-side compatibility. Each option could be set to 'Required', 'Optional', or 'Unsupported'. This did the job, but it left some ambiguities and led to many confused creators mis-labeling their mods.
|
||||
|
||||
1. **Certain combinations of options don't make logical sense**, such as both sides being 'Unsupported', or one side being 'Optional' and the other being 'Unsupported'. To some people, they may feel that since _installing_ the mod is optional, that might be a logical choice, when users and automated tools might be expecting it to be labeled as 'Required'
|
||||
2. **Terms like 'Unsupported' are interpreted differently** by different people. If something is 'Unsupported' on the server-side, does that mean it crashes when installed on a server?
|
||||
3. **Most server-side only mods also work in Singleplayer**, even if they don't perform any functions on the client-side directly. Some creators of server-sided mods chose to mark client-side as 'Optional' because of this, even if it did absolutely nothing on the client-side because in order to use it in Singleplayer, you technically install it on the "client"
|
||||
4. **Not all real-world combinations even could be represented** by this old system. There are some mods that only make sense in a singleplayer environment, or some that only make sense on dedicated servers and _not_ in Singleplayer.
|
||||
5. **Conflicting information is out there** on what exactly these terms meant. The website told creators to treat the client and server as the _logical_ client and servers, but some other people's guides and tooling treated them as referring to the _physical_ client and server. This includes the Modrinth Pack (.mrpack) specification, which confusingly uses the same required/optional/unsupported terminology to refer to the physical sides when defining which files should be installed in the client and server distributions.
|
||||
|
||||
## How does the new system work?
|
||||
|
||||
The new system enumerates all expected use-cases into distinct options that can be handled in unique ways by tools like launchers, mod managers, and modpack assemblers.
|
||||
|
||||
The new options are as follows:
|
||||
|
||||
- **Client-side only** (`client_only`)
|
||||
- All functionality is performed exclusively on the client side. Should be compatible with vanilla servers.
|
||||
- Example: [Mod Menu](/mod/modmenu). It only adds a menu to view the list of mods installed on your client, which doesn't need to be installed on the server.
|
||||
- **Server-side only / Works in singleplayer** (`server_only`)
|
||||
- All functionality is performed exclusively on the server side. Should be compatible with vanilla clients if only installed on the server. Also works in Singleplayer.
|
||||
- Example: [YUNG's Bridges](/mod/yungs-bridges). It only adds structures which don't need to be present on the client-side.
|
||||
- **Server-side only / Dedicated server only** (`dedicated_server_only`)
|
||||
- Only runs on a dedicated server, and not in Singleplayer.
|
||||
- Example: [Better Fabric Console](/mod/better-fabric-console). Its functionality does not work in singleplayer, because it modifies the dedicated server console.
|
||||
- **Client and server / Required on both** (`client_and_server`)
|
||||
- Must be installed on both the client and server.
|
||||
- Example: [Cobblemon](/mod/cobblemon). It adds entities, blocks, and items that need to be on both the client and server to work.
|
||||
- **Client and server / Optional on client** (`server_only_client_optional`)
|
||||
- Must be on the server, but can be on the client as well for enhanced functionality
|
||||
- Example: [Polymer](/mod/polymer). It functions on the server-side, but if installed on the client it can improve the experience when playing on a server running Polymer.
|
||||
- **Client and server / Optional on server** (`client_only_server_optional`)
|
||||
- Must be on the client, but can be on the server as well for enhanced functionality
|
||||
- Example: [AppleSkin](/mod/appleskin). It functions on the client-side, but if installed on the server it can provide more accurate saturation information.
|
||||
- **Client or server / Works best on both** (`client_or_server_prefers_both`)
|
||||
- Can be installed on just the client or just the server to function, but functionality is enhanced when it is on both.
|
||||
- Example: [No Chat Reports](/mod/no-chat-reports). The mod functions on just the client or just the server, but each comes with drawbacks. For the best functionality, you need to install it on both.
|
||||
- **Client or server / Works the same on either** (`client_or_server`)
|
||||
- Can be installed on just the client or just the server, and either one would enable full functionality. There would be no reason to install it on both.
|
||||
- Example: [Entity View Distance](/mod/entity-view-distance). It lets you perform the same functionality of limiting entity view distance on either the client or the server.
|
||||
- **Singleplayer only** (`singleplayer_only`)
|
||||
- Only works in Singleplayer, does not function in a Multiplayer environment.
|
||||
- Example: [LAN Server Properties](/mod/lan-server-properties). It modifies a feature that only exists in Singleplayer, the Open to LAN menu.
|
||||
|
||||
## What's next
|
||||
|
||||
This is a great first step towards us fixing many common issues that have been affecting Modrinth users, such as:
|
||||
|
||||
- Client-side mods being installed to Modrinth Servers, causing crashes
|
||||
- Modpack exporting in Modrinth App and other launchers using the Modrinth API such as Prism Launcher, MultiMC, and ATLauncher not having accurate and reliable metadata to pull from in order to build universal client and server Modrinth Pack files.
|
||||
|
||||
However, this is just the first step. Before we can improve the tooling around creating and using modpacks, we need as many Modrinth projects as possible to have accurate metadata.
|
||||
|
||||
Currently, the new environment metadata is also only available in the experimental API v3, which is _not_ intended for general use. When we're ready, we plan to integrate this metadata into API v2, so that it can be used in production by third parties. For now, developers using the Modrinth API should not worry about these environment changes, just keep them in mind for the future.
|
||||
|
||||
Thank you all for continuing to support Modrinth!
|
||||
|
||||
**Prospector**\
|
||||
_Founding Software Engineer_
|
||||
18
packages/blog/articles/russian-censorship.md
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
title: Russia forced us to do this
|
||||
summary: Update on censorship for Russian users and our continued support for those affected.
|
||||
date: 2025-09-30T18:30:00-07:00
|
||||
unlisted: true
|
||||
---
|
||||
|
||||
**This post has been preserved for archival reasons, but no longer reflects Modrinth policy on compliance with government censorship. Please see our updated post [here](../standing-by-our-values) on our choice to stand up to the Russian government.**
|
||||
|
||||
Hi everyone,
|
||||
|
||||
We want to be upfront about censorship on the Modrinth platform. Recently, the Russian government contacted us and required us to restrict LGBTQ+ projects for Russian users. If we didn’t, they threatened to block Modrinth entirely in Russia.
|
||||
|
||||
This is not a decision we believe in. It goes against our values and what we stand for as a community. But we were put in an impossible position: either comply, or cut off every single creator and player in Russia. That’s not a fair choice, and it doesn’t make us feel any better about having to go through with it.
|
||||
|
||||
**To be clear:** Modrinth supports the LGBTQ+ community. We always have, and we always will. If you’re part of this community, know that we see you, we value you, and we’re not going anywhere 💚. Modrinth will always be a safe home for you, and we’ll keep directly supporting the LGBTQ+ community through our annual Pride campaign fundraiser.
|
||||
|
||||
This sucks, and it hurts to be forced into this situation. But you deserve the truth, and we want you to hear it directly from us: **certain LGBTQ+ content on Modrinth will now be restricted in Russia.**
|
||||
53
packages/blog/articles/standing-by-our-values-russian.md
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
title: Сохраняя достоинство
|
||||
summary: Keeping LGBTQIA+ content visible despite demands from Russia.
|
||||
date: 2025-10-01T17:00:00-07:00
|
||||
unlisted: true
|
||||
---
|
||||
|
||||
Вчера, нас поставили в положение, в котором не должно оказаться ни одно сообщество. Российское правительство связалось с нами и потребовало немедленно удалить четыре ЛГБТК+ проекта с Modrinth. И если мы не согласимся, то весь сайт был бы заблокирован в России целиком.
|
||||
|
||||
У нас был всего день на решение. Вся команда Modrinth провела 5 часов, взвешивая наши опции. Одна хуже другой:
|
||||
|
||||
- Заблокировать весь Modrinth целиком более чем миллиону российских пользователей
|
||||
- Напрямую повлиять на жизни авторов: и собственно авторов-россиян, и тех, чьи работы популярны у россиян
|
||||
- Согласиться с запросами цензоров, которые идут против нашего достоинства
|
||||
|
||||
Деньги не были фактором нашего решения. Мы теряем деньги на контенте, который скачивают российские пользователи, но мы всё равно продолжаем поддерживать их. Для нас были значимы последствия для людей.
|
||||
|
||||

|
||||
_Доход от рекламы, показанной в России, за август._
|
||||
|
||||
В итоге, мы пришли к выводу, что мы выберем путь „наименьшего зла“. Мы были убеждены, что удалить те четыре проекта будет меньшей цензурой, чем если все пользователи из России утратят доступ к Modrinth. Но такое решение, сделанное под давлением и в отсутствие времени, было близоруким. Оно не соответствует нашим ценностям, нашей поддержке ЛГБТК+ сообщества и нашей позиции против цензуры.
|
||||
|
||||
Для полной ясности: решение подчиниться, даже кратковременно, было ошибкой. И мы очень просим за него прощения.
|
||||
|
||||
## Что теперь
|
||||
|
||||
Мы решили откатить прошлое решение. Все четыре проекта будут восстановлены.
|
||||
|
||||
Наши ценности не подлежать обсуждению. Идя дальше, мы не будем подчиняться нарушающим их требованиям никаких правительственных организаций. Modrinth существует, чтобы сделать моддинг игр открытым и доступным каждому, и мы следуем этой цели.
|
||||
|
||||
Мы знаем, что рано или поздно Modrinth будет заблокирован российским правительством, но мы постараемся сделать всё, что в наших силах, чтобы подготовить к этому российских пользователей. Мы оповестим их о надвигающемся бане и укажем как они могут сохранить доступ к веб-сайту.
|
||||
|
||||

|
||||
|
||||
## Последствия для авторов
|
||||
|
||||
Хоть этот откат и не повлияет на авторские отчисления напрямую, так как Modrinth не получали значимого дохода от российских пользователей, мы знаем, что он возымеет иное влияние на авторов.
|
||||
|
||||
Эта ситуация может подорвать Вашу жизнь и труды, если Вы - из России или у Вас в России большая публика. Это болезненно и несправедливо. Мы глубоко сожалеем о происходящем, и мы продолжим делать всё возможное, чтобы Вам помочь.
|
||||
|
||||
## Наш долг
|
||||
|
||||
Ото всей нашей команды: мы просим прощения. Мы позволили срочности и давлению увести нас к решению, которое не отражает того, кто мы и за что мы ратуем. Мы хотели бы быть искренни в том, что совершили ошибку и что возьмём ответственность за её исправление.
|
||||
|
||||
Наша цель остаётся прежней: поддерживать авторов, сохранять моддинг доступным и обеспечивать безопасное и гостеприимное место для каждого.
|
||||
|
||||
Спасибо за то, что призываете нас к ответственности. Спасибо за то, что доверяете, что мы исправимся. И мы надеемся, вы сможете продолжить нас поддерживать.
|
||||
|
||||
Если Вы в опасности или вам нужна информация, примите во внимание [Rainbow Railroad](https://www.rainbowrailroad.org/) - организацию, призванную помочь ЛГБТК+-людям спастись от угнетения.
|
||||
|
||||
💚 The Modrinth Team
|
||||
|
||||
(translated from [English](../standing-by-our-values))
|
||||
52
packages/blog/articles/standing-by-our-values.md
Normal file
@@ -0,0 +1,52 @@
|
||||
---
|
||||
title: Standing By Our Values
|
||||
summary: Keeping LGBTQIA+ content visible despite demands from Russia.
|
||||
date: 2025-10-01T17:00:00-07:00
|
||||
---
|
||||
|
||||
Yesterday, we were put in a position no community should have to face. The Russian government contacted us and demanded that four LGBTQ+ projects on Modrinth be removed immediately. If we did not comply, the entire site would have been blocked for everyone in Russia.
|
||||
|
||||
We had only that day to make a decision. The entire Modrinth team spent over 5 hours together weighing the options collectively. None of them were good:
|
||||
|
||||
- Restrict all content on Modrinth for over a million Russian users
|
||||
- Directly impact creators’ livelihoods, either Russian creators themselves or those whose work is widely played by Russian users
|
||||
- Comply with censorship requests that go directly against our values and beliefs
|
||||
|
||||
Money did not factor into our decision. It costs Modrinth hundreds of dollars a month in bandwidth cost alone to serve content to Russian users while we do not make ad revenue from them.
|
||||
|
||||

|
||||
_Ad revenue from Russian users for the month of September._
|
||||
|
||||
In the end, we told ourselves we were choosing the path of “harm reduction.” We believed that removing the four projects was less censorship than losing access to Modrinth entirely for Russian users. But that choice, made under pressure and with little time, was short-sighted. It was not consistent with our values, our support of the LGBTQ+ community, or our stance against censorship.
|
||||
|
||||
We want to be clear: the decision to comply, even briefly, was a mistake. And we are deeply sorry for it.
|
||||
|
||||
## What We’re Doing Now
|
||||
|
||||
We have decided to reverse that decision. The four projects in question will be fully restored for Russian users.
|
||||
Our values are not negotiable. Going forward, we will not comply with requests from any government body that go against them. Modrinth exists to make game modding open and accessible for all, and we stand by that mission.
|
||||
|
||||
We know Modrinth will be blocked by the Russian government at some point, but we will do everything we can to prepare Russian users for it. They will be informed of the upcoming ban and will be provided guidance on how they can continue accessing the website.
|
||||
|
||||

|
||||
|
||||
## Impact on Creators
|
||||
|
||||
Modrinth being banned in Russia will not significantly impact overall creator revenue, as very little comes from those users. However, it may affect individual creators in different ways.
|
||||
|
||||
For Russian creators, or anyone with a large audience in Russia, this ban could disrupt your revenue and livelihood. That is painful and unfair. We are deeply sorry for this happening and will continue to do everything we can to support you.
|
||||
|
||||
## Our Commitment
|
||||
|
||||
From our whole team: we are sorry. We let urgency and pressure steer us into a decision that did not reflect who we are or what we stand for. We want to be transparent about that mistake and take responsibility for making it right.
|
||||
|
||||
Our mission remains the same: to support creators, to keep modding accessible, and to provide a safe and welcoming place for everyone.
|
||||
|
||||
Thank you for holding us accountable. Thank you for trusting us to do better. And we hope you can continue to support us.
|
||||
If you are in danger or need resources, please consider reaching out to [Rainbow Railroad](https://www.rainbowrailroad.org/), an organization dedicated to helping LGBTQ+ people escape oppression.
|
||||
|
||||
💚 The Modrinth Team
|
||||
|
||||
[Also available in Russian / Перевод на русский](../standing-by-our-values-russian)
|
||||
|
||||
Our previous, outdated post announcing the Russian censorship can be found archived [here](../russian-censorship).
|
||||
@@ -3,6 +3,9 @@ import { promises as fs } from 'fs'
|
||||
import { glob } from 'glob'
|
||||
import matter from 'gray-matter'
|
||||
import { minify } from 'html-minifier-terser'
|
||||
import type { Options } from 'markdown-it'
|
||||
import type Renderer from 'markdown-it/lib/renderer.mjs'
|
||||
import type Token from 'markdown-it/lib/token.mjs'
|
||||
import * as path from 'path'
|
||||
import RSS from 'rss'
|
||||
import { parseStringPromise } from 'xml2js'
|
||||
@@ -59,21 +62,56 @@ async function compileArticles() {
|
||||
const src = await fs.readFile(file, 'utf8')
|
||||
const { content, data } = matter(src)
|
||||
|
||||
const { title, summary, date, slug: frontSlug, authors: authorsData, ...rest } = data
|
||||
const {
|
||||
title,
|
||||
summary,
|
||||
date,
|
||||
slug: frontSlug,
|
||||
authors: authorsData,
|
||||
unlisted: unlistedRaw,
|
||||
...rest
|
||||
} = data
|
||||
if (!title || !summary || !date) {
|
||||
console.error(`❌ Missing required frontmatter in ${file}. Required: title, summary, date`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const html = md().render(content)
|
||||
const minifiedHtml = await minify(html, {
|
||||
const mdIt = md()
|
||||
const slug = frontSlug || path.basename(file, '.md')
|
||||
|
||||
// Normalizes relative URL resolution to occur in the context of the article's directory.
|
||||
// This prevents user agents from resolving relative URLs differently based on whether
|
||||
// the current document URL has a trailing slash or not.
|
||||
function normalizeRendererHtmlUriAttribute(ruleName: string, attrName: string) {
|
||||
const defaultRenderer =
|
||||
mdIt.renderer.rules[ruleName] ||
|
||||
function (tokens, idx, options, _env, self) {
|
||||
return self.renderToken(tokens, idx, options)
|
||||
}
|
||||
|
||||
return (tokens: Token[], idx: number, options: Options, env: object, self: Renderer) => {
|
||||
const attrUrlValue = tokens[idx].attrGet(attrName)
|
||||
if (attrUrlValue) {
|
||||
tokens[idx].attrSet(
|
||||
attrName,
|
||||
new URL(attrUrlValue, `${SITE_URL}/news/article/${slug}/`).href.replace(SITE_URL, ''),
|
||||
)
|
||||
}
|
||||
return defaultRenderer(tokens, idx, options, env, self)
|
||||
}
|
||||
}
|
||||
|
||||
mdIt.renderer.rules.image = normalizeRendererHtmlUriAttribute('image', 'src')
|
||||
mdIt.renderer.rules.link_open = normalizeRendererHtmlUriAttribute('link_open', 'href')
|
||||
|
||||
const minifiedHtml = await minify(mdIt.render(content), {
|
||||
collapseWhitespace: true,
|
||||
removeComments: true,
|
||||
})
|
||||
|
||||
const authors = authorsData ? authorsData : []
|
||||
const unlisted = !!unlistedRaw
|
||||
|
||||
const slug = frontSlug || path.basename(file, '.md')
|
||||
const varName = toVarName(slug)
|
||||
const exportFile = path.posix.join(COMPILED_DIR, `${varName}.ts`)
|
||||
const contentFile = path.posix.join(COMPILED_DIR, `${varName}.content.ts`)
|
||||
@@ -94,6 +132,7 @@ export const article = {
|
||||
date: ${JSON.stringify(date)},
|
||||
slug: ${JSON.stringify(slug)},
|
||||
authors: ${JSON.stringify(authors)},
|
||||
unlisted: ${JSON.stringify(unlisted)},
|
||||
thumbnail: ${thumbnailPresent},
|
||||
${Object.keys(rest)
|
||||
.map((k) => `${k}: ${JSON.stringify(rest[k])},`)
|
||||
@@ -105,21 +144,23 @@ export const article = {
|
||||
articleExports.push(`import { article as ${varName} } from "./${varName}";`)
|
||||
articlesArray.push(varName)
|
||||
|
||||
articlesForRss.push({
|
||||
title,
|
||||
summary,
|
||||
date,
|
||||
slug,
|
||||
html: minifiedHtml,
|
||||
} as never)
|
||||
if (!unlisted) {
|
||||
articlesForRss.push({
|
||||
title,
|
||||
summary,
|
||||
date,
|
||||
slug,
|
||||
html: minifiedHtml,
|
||||
} as never)
|
||||
|
||||
articlesForJson.push({
|
||||
title,
|
||||
summary,
|
||||
thumbnail: getThumbnailUrl(slug, thumbnailPresent),
|
||||
date: new Date(date).toISOString(),
|
||||
link: getArticleLink(slug),
|
||||
} as never)
|
||||
articlesForJson.push({
|
||||
title,
|
||||
summary,
|
||||
thumbnail: getThumbnailUrl(slug, thumbnailPresent),
|
||||
date: new Date(date).toISOString(),
|
||||
link: getArticleLink(slug),
|
||||
} as never)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📂 Compiled ${files.length} articles.`)
|
||||
@@ -212,7 +253,7 @@ async function generateJsonFile(articles): Promise<void> {
|
||||
)
|
||||
const json = { articles: sorted }
|
||||
await fs.mkdir(path.dirname(JSON_PATH), { recursive: true })
|
||||
await fs.writeFile(JSON_PATH, JSON.stringify(json, null, 2) + '\n', 'utf8')
|
||||
await fs.writeFile(JSON_PATH, JSON.stringify(json, null, '\t') + '\n', 'utf8')
|
||||
console.log(`📝 Wrote JSON articles to ${JSON_PATH}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// AUTO-GENERATED FILE - DO NOT EDIT
|
||||
export const html = `<p>Over the few months, Modrinth has seen incredible interest towards our Servers product, and with significant growth, our vision for what Modrinth Servers can be has evolved alongside it. To continue striving towards our goal of providing the best place to get your own Minecraft multiplayer server, we’ve made the decision to bring our server hosting fully in-house.</p><h3>Why We're Making This Change</h3><p>Modrinth has some ambitious goals for the next year. We want to create the best possible way for all Java players play Minecraft, and to host and play their favorite modpacks and custom servers. To achieve this, it’s clear that Modrinth Servers needs to be built and scaled on our own infrastructure.</p><p>By running every aspect of our hosting platform, we gain the flexibility to tailor the experience to our community’s needs—whether that means deeper integrations with Modrinth’s ecosystem, better performance, or more innovative features. This also allows us to invest in the long-term sustainability of Modrinth Servers, ensuring that we can scale seamlessly and avoid running out of available servers stock.</p><h3>A Thank You to Pyro</h3><p>This change is purely a logistical step forward and does not reflect negatively on our partnership with <a href="https://pyro.host" rel="noopener nofollow ugc">Pyro</a>. In fact, Pyro has been an incredible partner in getting Modrinth Servers off the ground and we are very grateful for their collaboration. We completely support Pyro and their future, and we know they’re working on some exciting new products of their own, which we can’t wait to check out!</p><h3>What This Means for You</h3><p>We know you may have questions, and we want to make this transition as smooth as possible.</p><ul><li><p><strong>What part of my server was being run by Pyro?</strong></p><p>Until this point, Pyro has been responsible for the physical server machines that run your Modrinth servers. This means that they have been responsible for the hardware that powers your server, as well as the files and data for them. Moving forward, all of this will exist under Modrinth.</p></li><li><p><strong>What happens to my running servers?</strong></p><p>Your current servers will continue running, and we’ll provide a clear migration path if any action is needed on your part. You can expect a follow up soon, however our goal is to do this with 0 downtime or impact to you if possible.</p></li><li><p><strong>Will anything else change that impacts me?</strong></p><p>Modrinth Servers will remain the same great experience its has been, you likely won’t notice any changes right away. Long term, this means we’ll be able to improve both the stability of servers as well as the features that make managing your server a breeze.</p></li></ul><p>This is an exciting step toward a future where Modrinth is the go-to destination for Java Minecraft players—not just for mods and mod-packs, but for hosting and playing too. We appreciate your support and can’t wait to share more soon!</p>`;
|
||||
export const html = `<p>Over the few months, Modrinth has seen incredible interest towards our Servers product, and with significant growth, our vision for what Modrinth Servers can be has evolved alongside it. To continue striving towards our goal of providing the best place to get your own Minecraft multiplayer server, we’ve made the decision to bring our server hosting fully in-house.</p><h3>Why We're Making This Change</h3><p>Modrinth has some ambitious goals for the next year. We want to create the best possible way for all Java players play Minecraft, and to host and play their favorite modpacks and custom servers. To achieve this, it’s clear that Modrinth Servers needs to be built and scaled on our own infrastructure.</p><p>By running every aspect of our hosting platform, we gain the flexibility to tailor the experience to our community’s needs—whether that means deeper integrations with Modrinth’s ecosystem, better performance, or more innovative features. This also allows us to invest in the long-term sustainability of Modrinth Servers, ensuring that we can scale seamlessly and avoid running out of available servers stock.</p><h3>A Thank You to Pyro</h3><p>This change is purely a logistical step forward and does not reflect negatively on our partnership with <a href="https://pyro.host/" rel="noopener nofollow ugc">Pyro</a>. In fact, Pyro has been an incredible partner in getting Modrinth Servers off the ground and we are very grateful for their collaboration. We completely support Pyro and their future, and we know they’re working on some exciting new products of their own, which we can’t wait to check out!</p><h3>What This Means for You</h3><p>We know you may have questions, and we want to make this transition as smooth as possible.</p><ul><li><p><strong>What part of my server was being run by Pyro?</strong></p><p>Until this point, Pyro has been responsible for the physical server machines that run your Modrinth servers. This means that they have been responsible for the hardware that powers your server, as well as the files and data for them. Moving forward, all of this will exist under Modrinth.</p></li><li><p><strong>What happens to my running servers?</strong></p><p>Your current servers will continue running, and we’ll provide a clear migration path if any action is needed on your part. You can expect a follow up soon, however our goal is to do this with 0 downtime or impact to you if possible.</p></li><li><p><strong>Will anything else change that impacts me?</strong></p><p>Modrinth Servers will remain the same great experience its has been, you likely won’t notice any changes right away. Long term, this means we’ll be able to improve both the stability of servers as well as the features that make managing your server a breeze.</p></li></ul><p>This is an exciting step toward a future where Modrinth is the go-to destination for Java Minecraft players—not just for mods and mod-packs, but for hosting and playing too. We appreciate your support and can’t wait to share more soon!</p>`;
|
||||
|
||||
@@ -6,6 +6,7 @@ export const article = {
|
||||
date: "2025-03-13T00:00:00.000Z",
|
||||
slug: "a-new-chapter-for-modrinth-servers",
|
||||
authors: ["MpxzqsyW","Dc7EYhxG"],
|
||||
unlisted: false,
|
||||
thumbnail: true,
|
||||
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ export const article = {
|
||||
date: "2023-02-01T20:00:00.000Z",
|
||||
slug: "accelerating-development",
|
||||
authors: ["MpxzqsyW","Dc7EYhxG","6plzAzU4"],
|
||||
unlisted: false,
|
||||
thumbnail: false,
|
||||
|
||||
};
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// AUTO-GENERATED FILE - DO NOT EDIT
|
||||
export const html = `<p>Just over 3 weeks ago, we <a href="/news/article/introducing-modrinth-refreshed-site-look-new-advertising-system" rel="noopener nofollow ugc">launched</a> our new ads powered by <a href="https://www.aditude.com/" rel="noopener nofollow ugc">Aditude</a>. These ads have allowed us to improve creator revenue drastically and become sustainable. Read on for more info!</p><h2>Creator Revenue</h2><p>We’re excited to share we have been able to increase creator revenue by 5-8x what it was before!</p><p>There’s a couple changes to how revenue is distributed out to creators coming with this increase.</p><p>First, revenue is no longer entirely paid out the day they are earned. Previously, we used our own in-house advertisement deal which paid us in advance for the entire month, and we divided that among each day in the month, as the month progressed. With the switch to a more traditional ad network, we are paid on a NET 60 basis, which is fairly standard with ad networks. What this means is that some of your revenue may be pending until the ad network pays us out. Exactly how this works is explained further <a href="legal/cmp-info#pending" rel="noopener nofollow ugc">here</a>.</p><p>Second, the revenue split between Modrinth and Creators has changed. See the next section on sustainability for more on this.</p><p><img src="./abnormally-high-revenue.webp" alt="Some creators have wondered if the new revenue is a bug because it’s gone up so much!"></p><h2>Becoming Sustainable</h2><p>We have updated the Modrinth creator revenue split from 90/10 to 75/25. However, all of the increases listed above are with the new rate included, so while the percentage is lower, the overall revenue is much, much higher.</p><p>While 90% is a more remarkable figure, we changed it in order to ensure we can keep running Modrinth and continue to grow creator revenue without having to worry about losing money on operational costs.</p><p>Through these changes, we are proud to announce Modrinth is now fully sustainable with the new income, with all hosting and operational costs accounted for (including paying our developers, moderators, and support staff!) With the new revenue, users will see reduced support times and we will be able to ship bigger and better updates quicker to you all!</p><p>In an effort to be more transparent with our community than ever before, we are opening up as many of our finances as possible so you all can know how we’re doing and where all the money is going. We’re working to develop a transparency page on our website for you to view all the graphs and numbers, but it wasn’t ready in time for this blog post (for now, you can view our site-wide ad revenue in the API <a href="https://api.modrinth.com/v3/payout/platform_revenue" rel="noopener nofollow ugc">here</a>. We also plan to publish monthly transparency reports with more details about our revenue and expenses, the first of which should be available in early October, so keep an eye out for that.</p><p>For now, we can tell you that creators on Modrinth have earned a total of $160,868 on Modrinth to date (as of September 13, 2024), and here’s a graph of our revenue from the past 30 days:</p><p><img src="./revenue.webp" alt="Modrinth Advertising Revenue (last 30 days)"></p><p>We have a lot of exciting things coming up still, and of course, we greatly appreciate all of your support!</p>`;
|
||||
export const html = `<p>Just over 3 weeks ago, we <a href="/news/article/introducing-modrinth-refreshed-site-look-new-advertising-system" rel="noopener nofollow ugc">launched</a> our new ads powered by <a href="https://www.aditude.com/" rel="noopener nofollow ugc">Aditude</a>. These ads have allowed us to improve creator revenue drastically and become sustainable. Read on for more info!</p><h2>Creator Revenue</h2><p>We’re excited to share we have been able to increase creator revenue by 5-8x what it was before!</p><p>There’s a couple changes to how revenue is distributed out to creators coming with this increase.</p><p>First, revenue is no longer entirely paid out the day they are earned. Previously, we used our own in-house advertisement deal which paid us in advance for the entire month, and we divided that among each day in the month, as the month progressed. With the switch to a more traditional ad network, we are paid on a NET 60 basis, which is fairly standard with ad networks. What this means is that some of your revenue may be pending until the ad network pays us out. Exactly how this works is explained further <a href="/news/article/becoming-sustainable/legal/cmp-info#pending" rel="noopener nofollow ugc">here</a>.</p><p>Second, the revenue split between Modrinth and Creators has changed. See the next section on sustainability for more on this.</p><p><img src="/news/article/becoming-sustainable/abnormally-high-revenue.webp" alt="Some creators have wondered if the new revenue is a bug because it’s gone up so much!"></p><h2>Becoming Sustainable</h2><p>We have updated the Modrinth creator revenue split from 90/10 to 75/25. However, all of the increases listed above are with the new rate included, so while the percentage is lower, the overall revenue is much, much higher.</p><p>While 90% is a more remarkable figure, we changed it in order to ensure we can keep running Modrinth and continue to grow creator revenue without having to worry about losing money on operational costs.</p><p>Through these changes, we are proud to announce Modrinth is now fully sustainable with the new income, with all hosting and operational costs accounted for (including paying our developers, moderators, and support staff!) With the new revenue, users will see reduced support times and we will be able to ship bigger and better updates quicker to you all!</p><p>In an effort to be more transparent with our community than ever before, we are opening up as many of our finances as possible so you all can know how we’re doing and where all the money is going. We’re working to develop a transparency page on our website for you to view all the graphs and numbers, but it wasn’t ready in time for this blog post (for now, you can view our site-wide ad revenue in the API <a href="https://api.modrinth.com/v3/payout/platform_revenue" rel="noopener nofollow ugc">here</a>. We also plan to publish monthly transparency reports with more details about our revenue and expenses, the first of which should be available in early October, so keep an eye out for that.</p><p>For now, we can tell you that creators on Modrinth have earned a total of $160,868 on Modrinth to date (as of September 13, 2024), and here’s a graph of our revenue from the past 30 days:</p><p><img src="/news/article/becoming-sustainable/revenue.webp" alt="Modrinth Advertising Revenue (last 30 days)"></p><p>We have a lot of exciting things coming up still, and of course, we greatly appreciate all of your support!</p>`;
|
||||
|
||||
@@ -6,6 +6,7 @@ export const article = {
|
||||
date: "2024-09-13T20:00:00.000Z",
|
||||
slug: "becoming-sustainable",
|
||||
authors: ["MpxzqsyW","Dc7EYhxG"],
|
||||
unlisted: false,
|
||||
thumbnail: true,
|
||||
short_title: "Becoming Sustainable",
|
||||
short_summary: "Announcing 5x creator revenue and updates to the monetization program.",
|
||||
|
||||
@@ -6,6 +6,7 @@ export const article = {
|
||||
date: "2024-04-04T20:00:00.000Z",
|
||||
slug: "capital-return",
|
||||
authors: ["MpxzqsyW"],
|
||||
unlisted: false,
|
||||
thumbnail: false,
|
||||
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ export const article = {
|
||||
date: "2022-09-08T00:00:00.000Z",
|
||||
slug: "carbon-ads",
|
||||
authors: ["6plzAzU4"],
|
||||
unlisted: false,
|
||||
thumbnail: true,
|
||||
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ export const article = {
|
||||
date: "2022-11-12T00:00:00.000Z",
|
||||
slug: "creator-monetization",
|
||||
authors: ["6plzAzU4"],
|
||||
unlisted: false,
|
||||
thumbnail: true,
|
||||
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ export const article = {
|
||||
date: "2024-01-06T20:00:00.000Z",
|
||||
slug: "creator-update",
|
||||
authors: ["6plzAzU4"],
|
||||
unlisted: false,
|
||||
thumbnail: true,
|
||||
short_title: "The Creator Update",
|
||||
short_summary: "Adding analytics, orgs, collections, and more!",
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// AUTO-GENERATED FILE - DO NOT EDIT
|
||||
export const html = `<p>Hey all,</p><p>The last few months have been quite hectic for Modrinth. We've experienced all-time highs in both traffic and new creators and have outgrown a lot of our existing systems, which has led to a lot of issues plaguing creators, especially.</p><p>The team has been super hard at work at this, and I'm really glad to announce that we've fixed most of these issues long term.</p><ol><li><p><strong>Upload issues (inputs not showing up, instability, etc)</strong></p><p>We've tracked these issues down to conflicting code between our ad provider and Modrinth's. For now, we've <strong>disabled ads for all logged in users across the site</strong> while we work on resolving these long term. Both web users and logged-in web users make a very small percentage of our ad revenue (7% for web and 0.05% for logged-in web users) so creators should see a very minimal revenue drop from this, and have a much better experience navigating and uploading to the site.</p></li><li><p><strong>Moderation and report response times</strong></p><p>Creators have had to wait, in some cases, weeks to get their projects reviewed. This is unacceptable on our part and we are actively overhauling our moderation tooling to improve the moderation experience (and lowering time spent per project). We've also hired 3 additional moderators/support staff (<strong>bringing our total to 7 and the total team to 17 people!</strong>). We're hoping to see a significant reduction in queue times over the coming weeks.</p></li><li><p><strong>Ad revenue instability</strong></p><p>While ad revenue is generally out of our control and tends to fluctuate a lot, on June 4th we noticed a sharp decrease in creator revenue (~35% less than normal levels). While our ad provider initially thought this was a display issue, after further inquiry there were 2 causes: 1) Google AdExchange falsely flagging our traffic as invalid 2) Amazon banning many gaming publishers from their network <a href="https://www.adweek.com/media/exclusive-ads-from-verizon-shell-and-others-ran-next-to-explicit-videos-on-top-android-app/" rel="noopener nofollow ugc">due to panic in the gaming ads space</a>. While the Amazon ban is now resolved, we no longer are running Google AdExchange in the desktop app due to invalid traffic issues. This will lead to a permanent revenue decrease (AdX contributed to ~20% of our ad revenue). We also updated our prebid version (the underlying tech used to run ad auctions) which has shown a measurable increase, bringing revenue back to "normal" levels. Overall, we are closely monitoring and will keep you all posted. However, despite all the issues, due to some end-of-quarter campaigns, <strong>revenue in June was an all time high, at $227k ($170k paid to creators)</strong>!</p></li><li><p><strong>Payout outages</strong></p><p>Creators should be able to withdraw their revenue at all times, but due to slow PayPal clearing times and poor planning by us, we've had multiple week long outages in withdrawals. While we do store funds 1:1, these "outages" happen because we primarily store creator funds in an FDIC-insured bank account, as we wouldn't want a PayPal/Tremendous account suspension to cause creators to lose funds. We've now set up internal reporting which should never cause this to happen again (or, if it does, drastically reduce the time payout outages happen)</p></li><li><p><strong>Platform Revenue Route</strong></p><p>Due to some unannounced breaking changes in Aditude's API, the platform revenue API was broken. It is now <a href="https://api.modrinth.com/v3/payout/platform_revenue" rel="noopener nofollow ugc">working</a>. You can also use <code>start</code> and <code>end</code> fields to filter any date range!</p></li><li><p><strong>API and Uptime</strong></p><p>We've migrated our infrastructure for the website, app, and servers to OVH over our existing non-redundant AWS system. We've hit 99.96% uptime on our API and 99.98% on Modrinth Servers!</p></li></ol><p>Thank you all for your patience! If you are having any more issues or have any questions about all of this, feel free to DM @geometrically on Discord or <a href="https://support.modrinth.com" rel="noopener nofollow ugc">start a support chat</a> and we will be happy to help!</p>`;
|
||||
export const html = `<p>Hey all,</p><p>The last few months have been quite hectic for Modrinth. We've experienced all-time highs in both traffic and new creators and have outgrown a lot of our existing systems, which has led to a lot of issues plaguing creators, especially.</p><p>The team has been super hard at work at this, and I'm really glad to announce that we've fixed most of these issues long term.</p><ol><li><p><strong>Upload issues (inputs not showing up, instability, etc)</strong></p><p>We've tracked these issues down to conflicting code between our ad provider and Modrinth's. For now, we've <strong>disabled ads for all logged in users across the site</strong> while we work on resolving these long term. Both web users and logged-in web users make a very small percentage of our ad revenue (7% for web and 0.05% for logged-in web users) so creators should see a very minimal revenue drop from this, and have a much better experience navigating and uploading to the site.</p></li><li><p><strong>Moderation and report response times</strong></p><p>Creators have had to wait, in some cases, weeks to get their projects reviewed. This is unacceptable on our part and we are actively overhauling our moderation tooling to improve the moderation experience (and lowering time spent per project). We've also hired 3 additional moderators/support staff (<strong>bringing our total to 7 and the total team to 17 people!</strong>). We're hoping to see a significant reduction in queue times over the coming weeks.</p></li><li><p><strong>Ad revenue instability</strong></p><p>While ad revenue is generally out of our control and tends to fluctuate a lot, on June 4th we noticed a sharp decrease in creator revenue (~35% less than normal levels). While our ad provider initially thought this was a display issue, after further inquiry there were 2 causes: 1) Google AdExchange falsely flagging our traffic as invalid 2) Amazon banning many gaming publishers from their network <a href="https://www.adweek.com/media/exclusive-ads-from-verizon-shell-and-others-ran-next-to-explicit-videos-on-top-android-app/" rel="noopener nofollow ugc">due to panic in the gaming ads space</a>. While the Amazon ban is now resolved, we no longer are running Google AdExchange in the desktop app due to invalid traffic issues. This will lead to a permanent revenue decrease (AdX contributed to ~20% of our ad revenue). We also updated our prebid version (the underlying tech used to run ad auctions) which has shown a measurable increase, bringing revenue back to "normal" levels. Overall, we are closely monitoring and will keep you all posted. However, despite all the issues, due to some end-of-quarter campaigns, <strong>revenue in June was an all time high, at $227k ($170k paid to creators)</strong>!</p></li><li><p><strong>Payout outages</strong></p><p>Creators should be able to withdraw their revenue at all times, but due to slow PayPal clearing times and poor planning by us, we've had multiple week long outages in withdrawals. While we do store funds 1:1, these "outages" happen because we primarily store creator funds in an FDIC-insured bank account, as we wouldn't want a PayPal/Tremendous account suspension to cause creators to lose funds. We've now set up internal reporting which should never cause this to happen again (or, if it does, drastically reduce the time payout outages happen)</p></li><li><p><strong>Platform Revenue Route</strong></p><p>Due to some unannounced breaking changes in Aditude's API, the platform revenue API was broken. It is now <a href="https://api.modrinth.com/v3/payout/platform_revenue" rel="noopener nofollow ugc">working</a>. You can also use <code>start</code> and <code>end</code> fields to filter any date range!</p></li><li><p><strong>API and Uptime</strong></p><p>We've migrated our infrastructure for the website, app, and servers to OVH over our existing non-redundant AWS system. We've hit 99.96% uptime on our API and 99.98% on Modrinth Servers!</p></li></ol><p>Thank you all for your patience! If you are having any more issues or have any questions about all of this, feel free to DM @geometrically on Discord or <a href="https://support.modrinth.com/" rel="noopener nofollow ugc">start a support chat</a> and we will be happy to help!</p>`;
|
||||
|
||||
@@ -6,6 +6,7 @@ export const article = {
|
||||
date: "2025-07-02T04:20:00.000Z",
|
||||
slug: "creator-updates-july-2025",
|
||||
authors: ["MpxzqsyW"],
|
||||
unlisted: false,
|
||||
thumbnail: false,
|
||||
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ export const article = {
|
||||
date: "2024-08-21T20:00:00.000Z",
|
||||
slug: "design-refresh",
|
||||
authors: ["MpxzqsyW","Dc7EYhxG"],
|
||||
unlisted: false,
|
||||
thumbnail: true,
|
||||
short_title: "Modrinth+ and New Ads",
|
||||
short_summary: "Introducing a new ad system, a subscription to remove ads, and a redesign of the website!",
|
||||
|
||||
@@ -6,6 +6,7 @@ export const article = {
|
||||
date: "2023-11-10T20:00:00.000Z",
|
||||
slug: "download-adjustment",
|
||||
authors: ["6plzAzU4","MpxzqsyW"],
|
||||
unlisted: false,
|
||||
thumbnail: false,
|
||||
short_title: "Correcting Inflated Download Counts",
|
||||
};
|
||||
|
||||
2
packages/blog/compiled/free_server_medal.content.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// AUTO-GENERATED FILE - DO NOT EDIT
|
||||
export const html = `<p>We’re excited to announce we’ve teamed up with <a href="http://medal.tv/" rel="noopener nofollow ugc">Medal.tv</a>, where millions of gamers share their favorite moments. Starting today, Modrinth users can claim a <strong>free 3GB US server for 5 days</strong> by completing a Medal quest!</p><p>It’s the perfect way to try out Modrinth Servers, completely on us.</p><h2>How It Works</h2><ol><li><a href="https://quests.medal.tv/modrinth" rel="noopener nofollow ugc">Download and install Medal</a></li><li>Start the Modrinth Quest</li><li>Save a clip playing your favourite Mod</li><li>Claim your reward</li><li>Manage your server on Modrinth</li></ol><p>No payment required. Just clip, claim, and start playing your favourite mods with your friends! Learn more about how it works <a href="https://quests.medal.tv/modrinth" rel="noopener nofollow ugc">here</a>.</p>`;
|
||||
12
packages/blog/compiled/free_server_medal.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// AUTO-GENERATED FILE - DO NOT EDIT
|
||||
export const article = {
|
||||
html: () => import(`./free_server_medal.content`).then(m => m.html),
|
||||
title: "Get a Free Modrinth Server",
|
||||
summary: "In partnership with Medal.tv, get a 5-day free trial for Modrinth Servers",
|
||||
date: "2025-08-20T22:25:00.000Z",
|
||||
slug: "free-server-medal",
|
||||
authors: ["AJfd8YH6"],
|
||||
unlisted: false,
|
||||
thumbnail: true,
|
||||
|
||||
};
|
||||
@@ -1,48 +1,60 @@
|
||||
// AUTO-GENERATED FILE - DO NOT EDIT
|
||||
import { article as windows_borderless_malware_disclosure } from "./windows_borderless_malware_disclosure";
|
||||
import { article as whats_modrinth } from "./whats_modrinth";
|
||||
import { article as a_new_chapter_for_modrinth_servers } from "./a_new_chapter_for_modrinth_servers";
|
||||
import { article as accelerating_development } from "./accelerating_development";
|
||||
import { article as becoming_sustainable } from "./becoming_sustainable";
|
||||
import { article as capital_return } from "./capital_return";
|
||||
import { article as carbon_ads } from "./carbon_ads";
|
||||
import { article as creator_monetization } from "./creator_monetization";
|
||||
import { article as creator_update } from "./creator_update";
|
||||
import { article as creator_updates_july_2025 } from "./creator_updates_july_2025";
|
||||
import { article as design_refresh } from "./design_refresh";
|
||||
import { article as download_adjustment } from "./download_adjustment";
|
||||
import { article as free_server_medal } from "./free_server_medal";
|
||||
import { article as knossos_v2_1_0 } from "./knossos_v2_1_0";
|
||||
import { article as licensing_guide } from "./licensing_guide";
|
||||
import { article as modpack_changes } from "./modpack_changes";
|
||||
import { article as modpacks_alpha } from "./modpacks_alpha";
|
||||
import { article as modrinth_app_beta } from "./modrinth_app_beta";
|
||||
import { article as modrinth_beta } from "./modrinth_beta";
|
||||
import { article as modrinth_servers_asia } from "./modrinth_servers_asia";
|
||||
import { article as modrinth_servers_beta } from "./modrinth_servers_beta";
|
||||
import { article as new_environments } from "./new_environments";
|
||||
import { article as new_site_beta } from "./new_site_beta";
|
||||
import { article as plugins_resource_packs } from "./plugins_resource_packs";
|
||||
import { article as pride_campaign_2025 } from "./pride_campaign_2025";
|
||||
import { article as redesign } from "./redesign";
|
||||
import { article as russian_censorship } from "./russian_censorship";
|
||||
import { article as skins_now_in_modrinth_app } from "./skins_now_in_modrinth_app";
|
||||
import { article as standing_by_our_values } from "./standing_by_our_values";
|
||||
import { article as standing_by_our_values_russian } from "./standing_by_our_values_russian";
|
||||
import { article as two_years_of_modrinth } from "./two_years_of_modrinth";
|
||||
import { article as two_years_of_modrinth_history } from "./two_years_of_modrinth_history";
|
||||
import { article as skins_now_in_modrinth_app } from "./skins_now_in_modrinth_app";
|
||||
import { article as redesign } from "./redesign";
|
||||
import { article as pride_campaign_2025 } from "./pride_campaign_2025";
|
||||
import { article as plugins_resource_packs } from "./plugins_resource_packs";
|
||||
import { article as new_site_beta } from "./new_site_beta";
|
||||
import { article as modrinth_servers_beta } from "./modrinth_servers_beta";
|
||||
import { article as modrinth_beta } from "./modrinth_beta";
|
||||
import { article as modrinth_app_beta } from "./modrinth_app_beta";
|
||||
import { article as modpacks_alpha } from "./modpacks_alpha";
|
||||
import { article as modpack_changes } from "./modpack_changes";
|
||||
import { article as licensing_guide } from "./licensing_guide";
|
||||
import { article as knossos_v2_1_0 } from "./knossos_v2_1_0";
|
||||
import { article as download_adjustment } from "./download_adjustment";
|
||||
import { article as design_refresh } from "./design_refresh";
|
||||
import { article as creator_updates_july_2025 } from "./creator_updates_july_2025";
|
||||
import { article as creator_update } from "./creator_update";
|
||||
import { article as creator_monetization } from "./creator_monetization";
|
||||
import { article as carbon_ads } from "./carbon_ads";
|
||||
import { article as capital_return } from "./capital_return";
|
||||
import { article as becoming_sustainable } from "./becoming_sustainable";
|
||||
import { article as accelerating_development } from "./accelerating_development";
|
||||
import { article as a_new_chapter_for_modrinth_servers } from "./a_new_chapter_for_modrinth_servers";
|
||||
import { article as whats_modrinth } from "./whats_modrinth";
|
||||
import { article as windows_borderless_malware_disclosure } from "./windows_borderless_malware_disclosure";
|
||||
|
||||
export const articles = [
|
||||
windows_borderless_malware_disclosure,
|
||||
whats_modrinth,
|
||||
two_years_of_modrinth,
|
||||
two_years_of_modrinth_history,
|
||||
standing_by_our_values,
|
||||
standing_by_our_values_russian,
|
||||
skins_now_in_modrinth_app,
|
||||
russian_censorship,
|
||||
redesign,
|
||||
pride_campaign_2025,
|
||||
plugins_resource_packs,
|
||||
new_site_beta,
|
||||
new_environments,
|
||||
modrinth_servers_beta,
|
||||
modrinth_servers_asia,
|
||||
modrinth_beta,
|
||||
modrinth_app_beta,
|
||||
modpacks_alpha,
|
||||
modpack_changes,
|
||||
licensing_guide,
|
||||
knossos_v2_1_0,
|
||||
free_server_medal,
|
||||
download_adjustment,
|
||||
design_refresh,
|
||||
creator_updates_july_2025,
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// AUTO-GENERATED FILE - DO NOT EDIT
|
||||
export const html = `<p>It's officially been a bit over a week since Modrinth launched out of beta. We have continued to make improvements to the user experience on <a href="https://modrinth.com">the website</a>.</p><h2>New features</h2><p>We've added a number of new features to improve your experience.</p><h3>Click to expand gallery images</h3><p><img src="./expand-gallery.jpg" alt="The new expanding gallery images"></p><p>In the gallery page of a project, you can now click on the images to expand the image and view it more closely. You can also use the left arrow, right arrow, and Escape keyboard keys to aid navigation.</p><h3>Filters for the 'Changelog' and 'Versions' pages</h3><p><img src="./version-filters.jpg" alt="The new changelog and versions filtering options"></p><p>Versions on the Changelog and Versions page can now be filtered by mod loader and Minecraft version.</p><h3>More easily access the list of projects you follow</h3><p><img src="./following.jpg" alt="The new 'Following' button in the profile dropdown"></p><p>The link to the list of your followed projects is now listed in your profile dropdown.</p><h2>Fixes and Changes</h2><p>While new features are great, we've also been working on a bunch of bugfixes. Below is a list of some of the notable fixes, but it is not a comprehensive list.</p><ul><li>Improved the layout of the search page's search bar and options card to more dynamically adjust to screen size</li><li>Changed the tab indicator to be rounded</li><li>Changed the download icon to be more recognizable</li><li>Changed the profile dropdown caret to use an SVG instead of a text symbol for better font support</li><li>Changed the styling on text fields to be more consistent with the design language of the site</li><li>Changed the styling on disabled buttons to use an outline to reduce confusion</li><li>Changed the styling on links to be more consistent and obvious</li><li>Changed the wording of the options that move the sidebars to the right</li><li>Changed the green syntax highlighting in code blocks to match the brand color</li><li>Fixed the styling on various buttons and links that were missing hover or active states</li><li>Fixed the inconsistent rounding of the information card on the home page</li><li><a href="https://github.com/modrinth/knossos/issues/370" rel="noopener nofollow ugc">[GH-370]</a> Fixed download buttons in the changelog page</li><li><a href="https://github.com/modrinth/knossos/issues/384" rel="noopener nofollow ugc">[GH-384]</a> Fixed selecting too many Minecraft versions in the search page covering the license dropdown</li><li><a href="https://github.com/modrinth/knossos/issues/390" rel="noopener nofollow ugc">[GH-390]</a> Fixed the hover state of checkboxes not updating when clicking on the label</li><li><a href="https://github.com/modrinth/knossos/issues/393" rel="noopener nofollow ugc">[GH-393]</a> Fixed the padding of the donation link area when creating or editing a project</li><li><a href="https://github.com/modrinth/knossos/issues/394" rel="noopener nofollow ugc">[GH-394]</a> Fixed the rounding radius of dropdowns when opening upwards</li></ul><h2>Minotaur fixes</h2><p><a href="https://github.com/modrinth/minotaur" rel="noopener nofollow ugc">Minotaur</a>, our Gradle plugin, has also received a few fixes. This isn't going to be relevant to most people, but is relevant to some developers using this tool to deploy their mods.</p><ul><li>Debug mode (enabled through <code>debugMode = true</code>) allows previewing the data to be uploaded before uploading</li><li>Fix edge case with ForgeGradle due to broken publishing metadata</li><li>Fix game version detection on Fabric Loom 0.11</li><li>Fix <code>doLast</code> and related methods not being usable because the task was registered in <code>afterEvaluate</code></li></ul><p>These fixes should have been automatically pulled in, assuming you're using Minotaur <code>2.+</code>. If not, you should be upgrading to <code>2.0.2</code>.</p><p>Need a guide to migrate from Minotaur v1 to v2? Check the migration guide on the <a href="../redesign/#minotaur" rel="noopener nofollow ugc">redesign post</a>.</p>`;
|
||||
export const html = `<p>It's officially been a bit over a week since Modrinth launched out of beta. We have continued to make improvements to the user experience on <a href="/" rel="noopener nofollow ugc">the website</a>.</p><h2>New features</h2><p>We've added a number of new features to improve your experience.</p><h3>Click to expand gallery images</h3><p><img src="/news/article/knossos-v2.1.0/expand-gallery.jpg" alt="The new expanding gallery images"></p><p>In the gallery page of a project, you can now click on the images to expand the image and view it more closely. You can also use the left arrow, right arrow, and Escape keyboard keys to aid navigation.</p><h3>Filters for the 'Changelog' and 'Versions' pages</h3><p><img src="/news/article/knossos-v2.1.0/version-filters.jpg" alt="The new changelog and versions filtering options"></p><p>Versions on the Changelog and Versions page can now be filtered by mod loader and Minecraft version.</p><h3>More easily access the list of projects you follow</h3><p><img src="/news/article/knossos-v2.1.0/following.jpg" alt="The new 'Following' button in the profile dropdown"></p><p>The link to the list of your followed projects is now listed in your profile dropdown.</p><h2>Fixes and Changes</h2><p>While new features are great, we've also been working on a bunch of bugfixes. Below is a list of some of the notable fixes, but it is not a comprehensive list.</p><ul><li>Improved the layout of the search page's search bar and options card to more dynamically adjust to screen size</li><li>Changed the tab indicator to be rounded</li><li>Changed the download icon to be more recognizable</li><li>Changed the profile dropdown caret to use an SVG instead of a text symbol for better font support</li><li>Changed the styling on text fields to be more consistent with the design language of the site</li><li>Changed the styling on disabled buttons to use an outline to reduce confusion</li><li>Changed the styling on links to be more consistent and obvious</li><li>Changed the wording of the options that move the sidebars to the right</li><li>Changed the green syntax highlighting in code blocks to match the brand color</li><li>Fixed the styling on various buttons and links that were missing hover or active states</li><li>Fixed the inconsistent rounding of the information card on the home page</li><li><a href="https://github.com/modrinth/knossos/issues/370" rel="noopener nofollow ugc">[GH-370]</a> Fixed download buttons in the changelog page</li><li><a href="https://github.com/modrinth/knossos/issues/384" rel="noopener nofollow ugc">[GH-384]</a> Fixed selecting too many Minecraft versions in the search page covering the license dropdown</li><li><a href="https://github.com/modrinth/knossos/issues/390" rel="noopener nofollow ugc">[GH-390]</a> Fixed the hover state of checkboxes not updating when clicking on the label</li><li><a href="https://github.com/modrinth/knossos/issues/393" rel="noopener nofollow ugc">[GH-393]</a> Fixed the padding of the donation link area when creating or editing a project</li><li><a href="https://github.com/modrinth/knossos/issues/394" rel="noopener nofollow ugc">[GH-394]</a> Fixed the rounding radius of dropdowns when opening upwards</li></ul><h2>Minotaur fixes</h2><p><a href="https://github.com/modrinth/minotaur" rel="noopener nofollow ugc">Minotaur</a>, our Gradle plugin, has also received a few fixes. This isn't going to be relevant to most people, but is relevant to some developers using this tool to deploy their mods.</p><ul><li>Debug mode (enabled through <code>debugMode = true</code>) allows previewing the data to be uploaded before uploading</li><li>Fix edge case with ForgeGradle due to broken publishing metadata</li><li>Fix game version detection on Fabric Loom 0.11</li><li>Fix <code>doLast</code> and related methods not being usable because the task was registered in <code>afterEvaluate</code></li></ul><p>These fixes should have been automatically pulled in, assuming you're using Minotaur <code>2.+</code>. If not, you should be upgrading to <code>2.0.2</code>.</p><p>Need a guide to migrate from Minotaur v1 to v2? Check the migration guide on the <a href="/news/article/redesign/#minotaur" rel="noopener nofollow ugc">redesign post</a>.</p>`;
|
||||
|
||||
@@ -6,6 +6,7 @@ export const article = {
|
||||
date: "2022-03-09T00:00:00.000Z",
|
||||
slug: "knossos-v2.1.0",
|
||||
authors: ["Dc7EYhxG"],
|
||||
unlisted: false,
|
||||
thumbnail: true,
|
||||
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ export const article = {
|
||||
date: "2021-05-16T00:00:00.000Z",
|
||||
slug: "licensing-guide",
|
||||
authors: ["6plzAzU4","aNd6VJql"],
|
||||
unlisted: false,
|
||||
thumbnail: true,
|
||||
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ export const article = {
|
||||
date: "2022-05-28T00:00:00.000Z",
|
||||
slug: "modpack-changes",
|
||||
authors: ["MpxzqsyW","Dc7EYhxG"],
|
||||
unlisted: false,
|
||||
thumbnail: true,
|
||||
|
||||
};
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// AUTO-GENERATED FILE - DO NOT EDIT
|
||||
export const html = `<p>After over a year of development, Modrinth is happy to announce that modpack support is now in alpha testing!</p><p>What does alpha mean, exactly? Principally, it means that <strong>modpack support is still unstable</strong> and that not everything is perfect yet. However, we believe it to be complete enough that it can be released for general use and testing.</p><p>From this point forward, Modrinth has shifted development effort from modpacks to creator payouts. This long-anticipated feature means that mod developers, modpack creators, and anyone else who uploads content to Modrinth will be eligible to get the ad revenue generated from their project pages.</p><h2>Where can I find them?</h2><p>Right next to mods on the site! URLs to modpacks are the same as mods, just with <code>mod</code> replaced with <code>modpacks</code>, so you can find the search at <a href="https://modrinth.com/modpacks">https://modrinth.com/modpacks</a>.</p><p>Over a dozen modpacks have already been created by our early pack adopters, and those are available for download right now!</p><h2>Wait, so how do I download them?</h2><p>At this point in time, the only stable way to download modpacks and use them is through <a href="https://atlauncher.com" rel="noopener nofollow ugc">ATLauncher</a>. You can also install Modrinth packs if you switch to the development branch of <a href="https://multimc.org" rel="noopener nofollow ugc">MultiMC</a>. We're hoping to be supported by more launchers in the future, including our own launcher, which is still in development. Our <a href="https://docs.modrinth.com/docs/modpacks/playing_modpacks/" rel="noopener nofollow ugc">documentation for playing modpacks</a> will always have an up-to-date listing of the most popular ways to play packs.</p><h2>How do I create packs?</h2><p>You can either use <a href="https://atlauncher.com" rel="noopener nofollow ugc">ATLauncher</a> or <a href="https://github.com/packwiz/packwiz" rel="noopener nofollow ugc">packwiz</a> to create modpacks. The <a href="https://docs.modrinth.com/docs/modpacks/format_definition/" rel="noopener nofollow ugc">Modrinth format</a> is unique for our purposes, which is specifically in order to allow mods from multiple platforms to be in a pack. Our <a href="https://docs.modrinth.com/docs/modpacks/creating_modpacks/" rel="noopener nofollow ugc">documentation for creating modpacks</a> will always have an up-to-date listing of the most popular ways to create packs.</p><h2>Can I use CurseForge mods in my modpack?</h2><p>Yes! The <a href="https://docs.modrinth.com/docs/modpacks/format_definition/" rel="noopener nofollow ugc">Modrinth format</a> uses a link-based approach, meaning that theoretically, mods from any platform are usable. In practice, we are only allowing links from <strong>Modrinth</strong>, <strong>CurseForge</strong>, and <strong>GitHub</strong>. In the future, we may allow other sites.</p><h2>What happened to Theseus?</h2><p>For a while, we've been teasing Theseus, our own launcher. While lots of progress has been made on it, we haven't yet gotten it to a usable state even for alpha testing. Once we think it's usable, we will provide alpha builds -- however, for now, our main focus will be shifting to payouts, with Theseus development ramping up once that is out.</p><p>Remember: Modrinth only has a small team, and we have a lot of real-life responsibilities too. If you have experience in Rust or Svelte and would like to help out in developing it, please feel free to shoot a message in the <code>#launcher</code> channel in our <a href="https://discord.gg/EUHuJHt" rel="noopener nofollow ugc">Discord</a>.</p><h2>Conclusion</h2><p>All in all, this update is quite exciting for everyone involved. Just like with <a href="/packages/blog/articles/redesign.md" rel="noopener nofollow ugc">the redesign</a>, this is the culmination of months upon months of work, and modpack support is really a big stepping stone for what's still yet to come.</p><p>Remember: alpha means that it's still unstable! We are not expecting this release to go perfectly smoothly, but we still hope to provide the best modding experience possible. As always, the fastest and best way to get support is through our <a href="https://discord.gg/EUHuJHt" rel="noopener nofollow ugc">Discord</a>.</p><p>Next stop: creator payouts!</p>`;
|
||||
export const html = `<p>After over a year of development, Modrinth is happy to announce that modpack support is now in alpha testing!</p><p>What does alpha mean, exactly? Principally, it means that <strong>modpack support is still unstable</strong> and that not everything is perfect yet. However, we believe it to be complete enough that it can be released for general use and testing.</p><p>From this point forward, Modrinth has shifted development effort from modpacks to creator payouts. This long-anticipated feature means that mod developers, modpack creators, and anyone else who uploads content to Modrinth will be eligible to get the ad revenue generated from their project pages.</p><h2>Where can I find them?</h2><p>Right next to mods on the site! URLs to modpacks are the same as mods, just with <code>mod</code> replaced with <code>modpacks</code>, so you can find the search at <a href="/modpacks" rel="noopener nofollow ugc">https://modrinth.com/modpacks</a>.</p><p>Over a dozen modpacks have already been created by our early pack adopters, and those are available for download right now!</p><h2>Wait, so how do I download them?</h2><p>At this point in time, the only stable way to download modpacks and use them is through <a href="https://atlauncher.com/" rel="noopener nofollow ugc">ATLauncher</a>. You can also install Modrinth packs if you switch to the development branch of <a href="https://multimc.org/" rel="noopener nofollow ugc">MultiMC</a>. We're hoping to be supported by more launchers in the future, including our own launcher, which is still in development. Our <a href="https://docs.modrinth.com/docs/modpacks/playing_modpacks/" rel="noopener nofollow ugc">documentation for playing modpacks</a> will always have an up-to-date listing of the most popular ways to play packs.</p><h2>How do I create packs?</h2><p>You can either use <a href="https://atlauncher.com/" rel="noopener nofollow ugc">ATLauncher</a> or <a href="https://github.com/packwiz/packwiz" rel="noopener nofollow ugc">packwiz</a> to create modpacks. The <a href="https://docs.modrinth.com/docs/modpacks/format_definition/" rel="noopener nofollow ugc">Modrinth format</a> is unique for our purposes, which is specifically in order to allow mods from multiple platforms to be in a pack. Our <a href="https://docs.modrinth.com/docs/modpacks/creating_modpacks/" rel="noopener nofollow ugc">documentation for creating modpacks</a> will always have an up-to-date listing of the most popular ways to create packs.</p><h2>Can I use CurseForge mods in my modpack?</h2><p>Yes! The <a href="https://docs.modrinth.com/docs/modpacks/format_definition/" rel="noopener nofollow ugc">Modrinth format</a> uses a link-based approach, meaning that theoretically, mods from any platform are usable. In practice, we are only allowing links from <strong>Modrinth</strong>, <strong>CurseForge</strong>, and <strong>GitHub</strong>. In the future, we may allow other sites.</p><h2>What happened to Theseus?</h2><p>For a while, we've been teasing Theseus, our own launcher. While lots of progress has been made on it, we haven't yet gotten it to a usable state even for alpha testing. Once we think it's usable, we will provide alpha builds -- however, for now, our main focus will be shifting to payouts, with Theseus development ramping up once that is out.</p><p>Remember: Modrinth only has a small team, and we have a lot of real-life responsibilities too. If you have experience in Rust or Svelte and would like to help out in developing it, please feel free to shoot a message in the <code>#launcher</code> channel in our <a href="https://discord.gg/EUHuJHt" rel="noopener nofollow ugc">Discord</a>.</p><h2>Conclusion</h2><p>All in all, this update is quite exciting for everyone involved. Just like with <a href="/packages/blog/articles/redesign.md" rel="noopener nofollow ugc">the redesign</a>, this is the culmination of months upon months of work, and modpack support is really a big stepping stone for what's still yet to come.</p><p>Remember: alpha means that it's still unstable! We are not expecting this release to go perfectly smoothly, but we still hope to provide the best modding experience possible. As always, the fastest and best way to get support is through our <a href="https://discord.gg/EUHuJHt" rel="noopener nofollow ugc">Discord</a>.</p><p>Next stop: creator payouts!</p>`;
|
||||
|
||||
@@ -6,6 +6,7 @@ export const article = {
|
||||
date: "2022-05-15T00:00:00.000Z",
|
||||
slug: "modpacks-alpha",
|
||||
authors: ["6plzAzU4"],
|
||||
unlisted: false,
|
||||
thumbnail: true,
|
||||
|
||||
};
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// AUTO-GENERATED FILE - DO NOT EDIT
|
||||
export const html = `<p>The past few months have been a bit quiet on our part, but that doesn’t mean we haven’t been working on anything. In fact, this is quite possibly our biggest update yet, bringing the much-anticipated Modrinth App to general availability, alongside several other major features. Let’s get right into it!</p><h2>Modrinth App Beta</h2><p>Most of our time has been spent working on <a href="/app" rel="noopener nofollow ugc">Modrinth App</a>. This launcher integrates tightly with the website, bringing you the same bank of mods, modpacks, data packs, shaders, and resource packs already available for download on Modrinth.</p><p>Alongside that, there are a wealth of other features for you to find, including:</p><ul><li>Full support for vanilla, Forge, Fabric, and Quilt</li><li>Full support for Windows, macOS, and Linux</li><li>Modrinth modpack importing, either through the website or through a .mrpack file</li><li>Modrinth modpack exporting to the .mrpack format to upload to the website or share with friends</li><li>Importing of instances from a variety of different launchers, including MultiMC, GDLauncher, ATLauncher, CurseForge, and Prism Launcher</li><li>The ability to update, add, and remove individual mods in a modpack</li><li>The ability to run different modded instances in parallel</li><li>The ability to view and share current and historical logs</li><li>An auto-updater to ensure the app is always up-to-date</li><li>An interactive tutorial to show you through the core features of the app</li><li>Performance through the roof, backed by Rust and Tauri (not Electron!)</li><li>Fully open-source under the GNU GPLv3 license</li></ul><p>More features will, of course, be coming in the future. This is being considered a <strong>beta release</strong>. Nonetheless, we’re still very proud of what we’ve already created, and we’re pleased to say that it’s available for download on our website <strong>right now</strong> at <a href="/app" rel="noopener nofollow ugc">https://modrinth.app</a>. Check it out, play around with it, and have fun!</p><h2>Authentication, scoped tokens, and security</h2><p>The second major thing we’re releasing today is a wide range of changes to our authentication system. Security is a top concern at Modrinth, especially following recent events in the modded Minecraft community when several individuals were compromised due to <a href="https://github.com/trigram-mrp/fractureiser/tree/main#readme" rel="noopener nofollow ugc">a virus</a>. While Modrinth was not affected directly by this attack, it provided a harrowing reminder of what we’re working with. That’s why we’re pleased to announce three major features today that will strengthen Modrinth’s security significantly: in-house authentication, two-factor authentication, and scoped personal access tokens.</p><h3>In-house authentication and two-factor authentication</h3><p><img src="./auth.jpg" alt="A screenshot of the new Modrinth sign-in page, showing options to sign in with Discord, GitHub, Microsoft, Google, Steam, GitLab, or with an email and password."></p><p>Until today, Modrinth has always used GitHub accounts exclusively for authentication. That changes now. Starting today, you can now connect your Discord, Microsoft, Google, Steam, and/or GitLab accounts to your Modrinth account. You may also forgo all six of those options and elect to use a good ol’ fashioned email and password. No problems with that! (If you’re curious, we store passwords hashed with the Argon2id method, meaning we couldn't read them even if we wanted to.)</p>`;
|
||||
export const html = `<p>The past few months have been a bit quiet on our part, but that doesn’t mean we haven’t been working on anything. In fact, this is quite possibly our biggest update yet, bringing the much-anticipated Modrinth App to general availability, alongside several other major features. Let’s get right into it!</p><h2>Modrinth App Beta</h2><p>Most of our time has been spent working on <a href="/app" rel="noopener nofollow ugc">Modrinth App</a>. This launcher integrates tightly with the website, bringing you the same bank of mods, modpacks, data packs, shaders, and resource packs already available for download on Modrinth.</p><p>Alongside that, there are a wealth of other features for you to find, including:</p><ul><li>Full support for vanilla, Forge, Fabric, and Quilt</li><li>Full support for Windows, macOS, and Linux</li><li>Modrinth modpack importing, either through the website or through a .mrpack file</li><li>Modrinth modpack exporting to the .mrpack format to upload to the website or share with friends</li><li>Importing of instances from a variety of different launchers, including MultiMC, GDLauncher, ATLauncher, CurseForge, and Prism Launcher</li><li>The ability to update, add, and remove individual mods in a modpack</li><li>The ability to run different modded instances in parallel</li><li>The ability to view and share current and historical logs</li><li>An auto-updater to ensure the app is always up-to-date</li><li>An interactive tutorial to show you through the core features of the app</li><li>Performance through the roof, backed by Rust and Tauri (not Electron!)</li><li>Fully open-source under the GNU GPLv3 license</li></ul><p>More features will, of course, be coming in the future. This is being considered a <strong>beta release</strong>. Nonetheless, we’re still very proud of what we’ve already created, and we’re pleased to say that it’s available for download on our website <strong>right now</strong> at <a href="/app" rel="noopener nofollow ugc">https://modrinth.app</a>. Check it out, play around with it, and have fun!</p><h2>Authentication, scoped tokens, and security</h2><p>The second major thing we’re releasing today is a wide range of changes to our authentication system. Security is a top concern at Modrinth, especially following recent events in the modded Minecraft community when several individuals were compromised due to <a href="https://github.com/trigram-mrp/fractureiser/tree/main#readme" rel="noopener nofollow ugc">a virus</a>. While Modrinth was not affected directly by this attack, it provided a harrowing reminder of what we’re working with. That’s why we’re pleased to announce three major features today that will strengthen Modrinth’s security significantly: in-house authentication, two-factor authentication, and scoped personal access tokens.</p><h3>In-house authentication and two-factor authentication</h3><p><img src="/news/article/modrinth-app-beta/auth.jpg" alt="A screenshot of the new Modrinth sign-in page, showing options to sign in with Discord, GitHub, Microsoft, Google, Steam, GitLab, or with an email and password."></p><p>Until today, Modrinth has always used GitHub accounts exclusively for authentication. That changes now. Starting today, you can now connect your Discord, Microsoft, Google, Steam, and/or GitLab accounts to your Modrinth account. You may also forgo all six of those options and elect to use a good ol’ fashioned email and password. No problems with that! (If you’re curious, we store passwords hashed with the Argon2id method, meaning we couldn't read them even if we wanted to.)</p>`;
|
||||
|
||||
@@ -6,6 +6,7 @@ export const article = {
|
||||
date: "2023-08-05T20:00:00.000Z",
|
||||
slug: "modrinth-app-beta",
|
||||
authors: ["6plzAzU4"],
|
||||
unlisted: false,
|
||||
thumbnail: false,
|
||||
short_title: "Modrinth App Beta and Upgraded Authentication",
|
||||
short_summary: "Launching Modrinth App Beta and upgrading authentication.",
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// AUTO-GENERATED FILE - DO NOT EDIT
|
||||
export const html = `<p>After six months of work, Modrinth enters Beta, helping modders host their mods with ease!</p><p>Six months ago, in order to fill a void in the modding community, the project that would eventually become Modrinth was founded. Modrinth was created to bring change to an otherwise stagnant landscape of hosts. Today, Modrinth enters Beta, a huge step forward for Modrinth!</p><p><img src="./new-design.jpg" alt="Modrinth's brand new design, rolling out with the launch of Beta"></p><blockquote><p>Modrinth's brand new design, rolling out with the launch of Beta</p></blockquote><h2>What's new?</h2><p>If you've checked out Modrinth in the past, here's the main things you'll notice that have changed:</p><ul><li>All new clean and modern design in both light and dark modes</li><li>Mods now display download counts correctly</li><li>Mod information can now be edited in the author Dashboard</li><li>More information can be added to mods</li></ul><h2>What's next?</h2><p>Modrinth is still in beta, of course, so there will be bugs. In the coming weeks and months, we will be prioritizing fixing the issues that currently exist and continue refining the design in areas that are rough.</p><p>If you find any, please report them to the issue tracker: <a href="https://github.com/modrinth/code/issues" rel="noopener nofollow ugc">https://github.com/modrinth/code/issues</a></p><p>If you would like to chat about Modrinth, our discord is open to all here: <a href="https://discord.modrinth.com" rel="noopener nofollow ugc">https://discord.modrinth.com</a></p>`;
|
||||
export const html = `<p>After six months of work, Modrinth enters Beta, helping modders host their mods with ease!</p><p>Six months ago, in order to fill a void in the modding community, the project that would eventually become Modrinth was founded. Modrinth was created to bring change to an otherwise stagnant landscape of hosts. Today, Modrinth enters Beta, a huge step forward for Modrinth!</p><p><img src="/news/article/modrinth-beta/new-design.jpg" alt="Modrinth's brand new design, rolling out with the launch of Beta"></p><blockquote><p>Modrinth's brand new design, rolling out with the launch of Beta</p></blockquote><h2>What's new?</h2><p>If you've checked out Modrinth in the past, here's the main things you'll notice that have changed:</p><ul><li>All new clean and modern design in both light and dark modes</li><li>Mods now display download counts correctly</li><li>Mod information can now be edited in the author Dashboard</li><li>More information can be added to mods</li></ul><h2>What's next?</h2><p>Modrinth is still in beta, of course, so there will be bugs. In the coming weeks and months, we will be prioritizing fixing the issues that currently exist and continue refining the design in areas that are rough.</p><p>If you find any, please report them to the issue tracker: <a href="https://github.com/modrinth/code/issues" rel="noopener nofollow ugc">https://github.com/modrinth/code/issues</a></p><p>If you would like to chat about Modrinth, our discord is open to all here: <a href="https://discord.modrinth.com/" rel="noopener nofollow ugc">https://discord.modrinth.com</a></p>`;
|
||||
|
||||
@@ -6,6 +6,7 @@ export const article = {
|
||||
date: "2020-12-01T00:00:00.000Z",
|
||||
slug: "modrinth-beta",
|
||||
authors: ["Dc7EYhxG"],
|
||||
unlisted: false,
|
||||
thumbnail: true,
|
||||
|
||||
};
|
||||
|
||||
2
packages/blog/compiled/modrinth_servers_asia.content.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// AUTO-GENERATED FILE - DO NOT EDIT
|
||||
export const html = `<p>We're happy to announce that we have just launched <a href="https://modrinth.gg/" rel="noopener nofollow ugc">Modrinth Servers</a> in one of the most highly anticipated regions: Southeast Asia.</p><h3>What does this mean for me?</h3><ul><li>Lower latency and smoother gameplay for players across Asia and nearby regions.</li><li>More choice when creating new servers — Singapore is available as a region starting today.</li><li>Room to grow as we continue rolling out infrastructure where you need it most.</li></ul><p>This launch is a big step in bringing Modrinth Servers closer to more of our community. And we’re just getting started.</p><p>In the next few months, we hope to unveil some exciting new changes to Modrinth Servers that will fundamentally change how you host Minecraft servers. Stay tuned and thank you all for your support since we launched 10 months ago!</p><p><strong data-contrast-text>Host your next server with <a href="https://modrinth.gg/" rel="noopener nofollow ugc">Modrinth Servers</a> today!</strong></p><p>What region should be next? <a href="https://surveys.modrinth.com/servers-region-waitlist" rel="noopener nofollow ugc">Let us know here</a>.</p>`;
|
||||
12
packages/blog/compiled/modrinth_servers_asia.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// AUTO-GENERATED FILE - DO NOT EDIT
|
||||
export const article = {
|
||||
html: () => import(`./modrinth_servers_asia.content`).then(m => m.html),
|
||||
title: "Modrinth Servers Launches in Asia",
|
||||
summary: "Our latest Modrinth Servers datacenter is in Singapore.",
|
||||
date: "2025-09-08T21:45:00.000Z",
|
||||
slug: "modrinth-servers-asia",
|
||||
authors: ["AJfd8YH6","Dc7EYhxG"],
|
||||
unlisted: false,
|
||||
thumbnail: true,
|
||||
|
||||
};
|
||||
@@ -1,2 +1,2 @@
|
||||
// AUTO-GENERATED FILE - DO NOT EDIT
|
||||
export const html = `<p>It's been almost <em>four</em> years since we publicly launched Modrinth Beta. Today, we're thrilled to unveil a new beta release of a product we've been eagerly developing: Modrinth Servers.</p><p>Modrinth Servers aims to provide the most seamless experience for running and playing on modded servers. To make this possible, we have partnered with our friends at the server hosting provider <a href="https://pyro.host" rel="noopener nofollow ugc">Pyro</a>. Together, we've developed fully custom software that gives us a unique advantage in scaling, offering new features and integrations that other hosts couldn't dream of.</p><p>For this beta launch, <strong>all servers are US-only</strong>. Please be aware of this if you are looking to purchase a server, as it may not be optimal for users outside of North America.</p><p><img src="./panel.jpg" alt="A screenshot of the fully-custom Modrinth Servers panel integrated into Modrinth"></p><h2>What makes Modrinth Servers unique?</h2><p>We understand that entering the server hosting industry might come as a surprise given the number of existing providers. Here's what sets Modrinth Servers apart:</p><h3>The most modern hardware</h3><p>Your modpack shouldn't have to run slow. All our servers are powered by cutting-edge 2023 Ryzen 7 and Ryzen 9 CPUs with DDR5 memory. From our research, we couldn't find any other Minecraft server host offering such modern hardware at any price point, much less at our affordably low one. This ensures smooth performance even with the most demanding modpacks.</p><h3>Seamless integration with Modrinth content</h3><p>Download mods and modpacks directly from Modrinth without any hassle. This deep integration simplifies server setup and management like never before. With just a few clicks, you can have your server up and running with your favorite mods.</p><h3>Fully custom panel and backend</h3><p>Unlike most other server hosts that rely on off-the-shelf software like Multicraft or Pterodactyl, Modrinth Servers is fully custom-built from front to back. This enables higher performance and much deeper integration than is otherwise possible. Our intuitive interface makes server management a breeze, even for newcomers.</p><h3>Dedicated support</h3><p>Our team is committed to providing exceptional support. Whether you're experiencing technical issues or have questions, we're here to ensure your experience with Modrinth Servers is top-notch.</p><h3>No tricky fees or up-charges</h3><p>Modrinth Servers are offered in a very simple Small, Medium, and Large pricing model, and are priced based on the amount of RAM at $3/GB. Custom URLs, port configuration, off-site backups, and plenty of storage is included in every Modrinth Server purchase at no additional cost.</p><h2>What’s next?</h2><p>As this is a beta release, there's much more to come for Modrinth Servers:</p><ul><li><strong>Global availability:</strong> We plan to expand to more worldwide regions and offer the ability to select a region for your server, ensuring optimal performance no matter where you are.</li><li><strong>Support more types of content:</strong> We'll be adding support for plugin loaders and improving support for data packs, giving you more flexibility and functionality</li><li><strong>Social features:</strong> A friends system to make sharing invites to servers easier, streamlining sharing custom-built modpacks and servers with your community.</li><li><strong>App integration:</strong> Full integration with Modrinth App, including the ability to sync an instance with a server or friends, making collaboration seamless.</li><li><strong>Collaborative management:</strong> Give other Modrinth users access to your server panel so you can manage your server with your team.</li><li><strong>Automatic creator commissions:</strong> Creators will automatically earn a portion of server proceeds when content is installed on a Modrinth Server.</li></ul><p>And so much more... stay tuned!</p><p>We can't wait for you to try out <a href="https://modrinth.gg" rel="noopener nofollow ugc">Modrinth Servers</a> and share your feedback. This is just the beginning, and we're excited to continue improving and expanding our services to better serve the Minecraft community.</p><p><strong>From the teams at Modrinth and Pyro, with <3</strong></p>`;
|
||||
export const html = `<p>It's been almost <em>four</em> years since we publicly launched Modrinth Beta. Today, we're thrilled to unveil a new beta release of a product we've been eagerly developing: Modrinth Servers.</p><p>Modrinth Servers aims to provide the most seamless experience for running and playing on modded servers. To make this possible, we have partnered with our friends at the server hosting provider <a href="https://pyro.host/" rel="noopener nofollow ugc">Pyro</a>. Together, we've developed fully custom software that gives us a unique advantage in scaling, offering new features and integrations that other hosts couldn't dream of.</p><p>For this beta launch, <strong>all servers are US-only</strong>. Please be aware of this if you are looking to purchase a server, as it may not be optimal for users outside of North America.</p><p><img src="/news/article/modrinth-servers-beta/panel.jpg" alt="A screenshot of the fully-custom Modrinth Servers panel integrated into Modrinth"></p><h2>What makes Modrinth Servers unique?</h2><p>We understand that entering the server hosting industry might come as a surprise given the number of existing providers. Here's what sets Modrinth Servers apart:</p><h3>The most modern hardware</h3><p>Your modpack shouldn't have to run slow. All our servers are powered by cutting-edge 2023 Ryzen 7 and Ryzen 9 CPUs with DDR5 memory. From our research, we couldn't find any other Minecraft server host offering such modern hardware at any price point, much less at our affordably low one. This ensures smooth performance even with the most demanding modpacks.</p><h3>Seamless integration with Modrinth content</h3><p>Download mods and modpacks directly from Modrinth without any hassle. This deep integration simplifies server setup and management like never before. With just a few clicks, you can have your server up and running with your favorite mods.</p><h3>Fully custom panel and backend</h3><p>Unlike most other server hosts that rely on off-the-shelf software like Multicraft or Pterodactyl, Modrinth Servers is fully custom-built from front to back. This enables higher performance and much deeper integration than is otherwise possible. Our intuitive interface makes server management a breeze, even for newcomers.</p><h3>Dedicated support</h3><p>Our team is committed to providing exceptional support. Whether you're experiencing technical issues or have questions, we're here to ensure your experience with Modrinth Servers is top-notch.</p><h3>No tricky fees or up-charges</h3><p>Modrinth Servers are offered in a very simple Small, Medium, and Large pricing model, and are priced based on the amount of RAM at $3/GB. Custom URLs, port configuration, off-site backups, and plenty of storage is included in every Modrinth Server purchase at no additional cost.</p><h2>What’s next?</h2><p>As this is a beta release, there's much more to come for Modrinth Servers:</p><ul><li><strong>Global availability:</strong> We plan to expand to more worldwide regions and offer the ability to select a region for your server, ensuring optimal performance no matter where you are.</li><li><strong>Support more types of content:</strong> We'll be adding support for plugin loaders and improving support for data packs, giving you more flexibility and functionality</li><li><strong>Social features:</strong> A friends system to make sharing invites to servers easier, streamlining sharing custom-built modpacks and servers with your community.</li><li><strong>App integration:</strong> Full integration with Modrinth App, including the ability to sync an instance with a server or friends, making collaboration seamless.</li><li><strong>Collaborative management:</strong> Give other Modrinth users access to your server panel so you can manage your server with your team.</li><li><strong>Automatic creator commissions:</strong> Creators will automatically earn a portion of server proceeds when content is installed on a Modrinth Server.</li></ul><p>And so much more... stay tuned!</p><p>We can't wait for you to try out <a href="https://modrinth.gg/" rel="noopener nofollow ugc">Modrinth Servers</a> and share your feedback. This is just the beginning, and we're excited to continue improving and expanding our services to better serve the Minecraft community.</p><p><strong>From the teams at Modrinth and Pyro, with <3</strong></p>`;
|
||||
|
||||
@@ -6,6 +6,7 @@ export const article = {
|
||||
date: "2024-11-03T06:00:00.000Z",
|
||||
slug: "modrinth-servers-beta",
|
||||
authors: ["MpxzqsyW","Dc7EYhxG"],
|
||||
unlisted: false,
|
||||
thumbnail: true,
|
||||
short_title: "Introducing Modrinth Servers",
|
||||
short_summary: "Host your next Minecraft server with Modrinth.",
|
||||
|
||||
2
packages/blog/compiled/new_environments.content.ts
Normal file
12
packages/blog/compiled/new_environments.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// AUTO-GENERATED FILE - DO NOT EDIT
|
||||
export const article = {
|
||||
html: () => import(`./new_environments.content`).then(m => m.html),
|
||||
title: "Creators: Verify Your Environment Metadata",
|
||||
summary: "We've overhauled the environment metadata on Modrinth, and all creators must verify their settings.",
|
||||
date: "2025-08-28T23:50:00.000Z",
|
||||
slug: "new-environments",
|
||||
authors: ["Dc7EYhxG"],
|
||||
unlisted: false,
|
||||
thumbnail: true,
|
||||
|
||||
};
|
||||
@@ -6,6 +6,7 @@ export const article = {
|
||||
date: "2023-04-01T08:00:00.000Z",
|
||||
slug: "new-site-beta",
|
||||
authors: [],
|
||||
unlisted: false,
|
||||
thumbnail: true,
|
||||
short_title: "(April Fools 2023) Modrinth Technologies™️ beta launch!",
|
||||
short_summary: "Power up your experience.",
|
||||
|
||||
@@ -6,6 +6,7 @@ export const article = {
|
||||
date: "2022-08-27T00:00:00.000Z",
|
||||
slug: "plugins-resource-packs",
|
||||
authors: ["6plzAzU4"],
|
||||
unlisted: false,
|
||||
thumbnail: true,
|
||||
|
||||
};
|
||||
|
||||