diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OfflineAccountSkinPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OfflineAccountSkinPane.java new file mode 100644 index 000000000..4cb90212b --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OfflineAccountSkinPane.java @@ -0,0 +1,35 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.account; + +import com.jfoenix.controls.JFXDialogLayout; +import javafx.scene.layout.StackPane; +import org.jackhuang.hmcl.auth.offline.OfflineAccount; +import org.jackhuang.hmcl.ui.construct.MultiFileItem; + +public class OfflineAccountSkinPane extends StackPane { + + public OfflineAccountSkinPane(OfflineAccount account) { + + JFXDialogLayout layout = new JFXDialogLayout(); + getChildren().setAll(layout); + + MultiFileItem<> + + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java index 76f7553b5..b8180356a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java @@ -51,15 +51,13 @@ public class OfflineAccount extends Account { private final AuthlibInjectorArtifactProvider downloader; private final String username; private final UUID uuid; - private final String skin; - private final String cape; + private final Map textures; - protected OfflineAccount(AuthlibInjectorArtifactProvider downloader, String username, UUID uuid, String skin, String cape) { + protected OfflineAccount(AuthlibInjectorArtifactProvider downloader, String username, UUID uuid, Map textures) { this.downloader = requireNonNull(downloader); this.username = requireNonNull(username); this.uuid = requireNonNull(uuid); - this.skin = skin; - this.cape = cape; + this.textures = textures; if (StringUtils.isBlank(username)) { throw new IllegalArgumentException("Username cannot be blank"); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Skin.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Skin.java new file mode 100644 index 000000000..169d09041 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Skin.java @@ -0,0 +1,202 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2020 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.auth.offline; + +import com.google.gson.annotations.SerializedName; +import com.google.gson.reflect.TypeToken; +import org.jackhuang.hmcl.auth.yggdrasil.TextureModel; +import org.jackhuang.hmcl.auth.yggdrasil.TextureType; +import org.jackhuang.hmcl.task.FetchTask; +import org.jackhuang.hmcl.task.GetTask; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jetbrains.annotations.Nullable; + +import java.io.*; +import java.net.URL; +import java.net.URLConnection; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.Map; + +import static org.jackhuang.hmcl.util.Lang.tryCast; + +public class Skin { + + public enum Type { + DEFAULT, + STEVE, + ALEX, + LOCAL_FILE, + CUSTOM_SKIN_LOADER_API, + YGGDRASIL_API + } + + private Type type; + private String value; + + public Type getType() { + return type; + } + + public String getValue() { + return value; + } + + public Task toTexture(String username) { + switch (type) { + case DEFAULT: + return Task.supplyAsync(() -> null); + case STEVE: + return Task.supplyAsync(() -> Texture.loadTexture(Skin.class.getResourceAsStream("/assets/img/steve.png"))); + case ALEX: + return Task.supplyAsync(() -> Texture.loadTexture(Skin.class.getResourceAsStream("/assets/img/alex.png"))); + case LOCAL_FILE: + return Task.supplyAsync(() -> Texture.loadTexture(Files.newInputStream(Paths.get(value)))); + case CUSTOM_SKIN_LOADER_API: + return Task.composeAsync(() -> new GetTask(new URL(String.format("%s/%s.json", value, username)))) + .thenComposeAsync(json -> { + SkinJson result = JsonUtils.GSON.fromJson(json, SkinJson.class); + + if (!result.hasSkin()) { + return Task.supplyAsync(() -> null); + } + + return new FetchBytesTask(new URL(String.format("%s/textures/%s", value, result.getHash())), 3); + }).thenApplyAsync(Texture::loadTexture); + default: + throw new UnsupportedOperationException(); + } + } + + private static class FetchBytesTask extends FetchTask { + + public FetchBytesTask(URL url, int retry) { + super(Collections.singletonList(url), retry); + } + + @Override + protected void useCachedResult(Path cachedFile) throws IOException { + setResult(Files.newInputStream(cachedFile)); + } + + @Override + protected EnumCheckETag shouldCheckETag() { + return EnumCheckETag.CHECK_E_TAG; + } + + @Override + protected Context getContext(URLConnection conn, boolean checkETag) throws IOException { + return new Context() { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + @Override + public void write(byte[] buffer, int offset, int len) { + baos.write(buffer, offset, len); + } + + @Override + public void close() throws IOException { + if (!isSuccess()) return; + + setResult(new ByteArrayInputStream(baos.toByteArray())); + + if (checkETag) { + repository.cacheBytes(baos.toByteArray(), conn); + } + } + }; + } + } + + private static class SkinJson { + private final String username; + private final String skin; + private final String cape; + private final String elytra; + + @SerializedName(value = "textures", alternate = { "skins" }) + private final TextureJson textures; + + public SkinJson(String username, String skin, String cape, String elytra, TextureJson textures) { + this.username = username; + this.skin = skin; + this.cape = cape; + this.elytra = elytra; + this.textures = textures; + } + + public boolean hasSkin() { + return StringUtils.isNotBlank(username); + } + + @Nullable + public TextureModel getModel() { + if (textures != null && textures.slim != null) { + return TextureModel.ALEX; + } else if (textures != null && textures.defaultSkin != null) { + return TextureModel.STEVE; + } else { + return null; + } + } + + public String getAlexModelHash() { + if (textures != null && textures.slim != null) { + return textures.slim; + } else { + return null; + } + } + + public String getSteveModelHash() { + if (textures != null && textures.defaultSkin != null) { + return textures.defaultSkin; + } else return skin; + } + + public String getHash() { + TextureModel model = getModel(); + if (model == TextureModel.ALEX) + return getAlexModelHash(); + else if (model == TextureModel.STEVE) + return getSteveModelHash(); + else + return null; + } + + public static class TextureJson { + @SerializedName("default") + private final String defaultSkin; + + private final String slim; + private final String cape; + private final String elytra; + + public TextureJson(String defaultSkin, String slim, String cape, String elytra) { + this.defaultSkin = defaultSkin; + this.slim = slim; + this.cape = cape; + this.elytra = elytra; + } + } + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Texture.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Texture.java new file mode 100644 index 000000000..807655674 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/Texture.java @@ -0,0 +1,140 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2020 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.auth.offline; + +import javax.imageio.IIOException; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.net.URL; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashMap; +import java.util.Map; + +import static java.util.Objects.requireNonNull; + +public class Texture { + private final String hash; + private final byte[] data; + + public Texture(String hash, byte[] data) { + this.hash = requireNonNull(hash); + this.data = requireNonNull(data); + } + + public String getHash() { + return hash; + } + + public InputStream getInputStream() { + return new ByteArrayInputStream(data); + } + + public int getLength() { + return data.length; + } + + private static final Map textures = new HashMap<>(); + + public static boolean hasTexture(String hash) { + return textures.containsKey(hash); + } + + public static Texture getTexture(String hash) { + return textures.get(hash); + } + + private static String computeTextureHash(BufferedImage img) { + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + int width = img.getWidth(); + int height = img.getHeight(); + byte[] buf = new byte[4096]; + + putInt(buf, 0, width); + putInt(buf, 4, height); + int pos = 8; + for (int x = 0; x < width; x++) { + for (int y = 0; y < height; y++) { + putInt(buf, pos, img.getRGB(x, y)); + if (buf[pos + 0] == 0) { + buf[pos + 1] = buf[pos + 2] = buf[pos + 3] = 0; + } + pos += 4; + if (pos == buf.length) { + pos = 0; + digest.update(buf, 0, buf.length); + } + } + } + if (pos > 0) { + digest.update(buf, 0, pos); + } + + byte[] sha256 = digest.digest(); + return String.format("%0" + (sha256.length << 1) + "x", new BigInteger(1, sha256)); + } + + private static void putInt(byte[] array, int offset, int x) { + array[offset + 0] = (byte) (x >> 24 & 0xff); + array[offset + 1] = (byte) (x >> 16 & 0xff); + array[offset + 2] = (byte) (x >> 8 & 0xff); + array[offset + 3] = (byte) (x >> 0 & 0xff); + } + + public static Texture loadTexture(InputStream in) throws IOException { + if (in == null) return null; + BufferedImage img = ImageIO.read(in); + if (img == null) { + throw new IIOException("No image found"); + } + + String hash = computeTextureHash(img); + + Texture existent = textures.get(hash); + if (existent != null) { + return existent; + } + + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + ImageIO.write(img, "png", buf); + Texture texture = new Texture(hash, buf.toByteArray()); + + existent = textures.putIfAbsent(hash, texture); + + if (existent != null) { + return existent; + } + return texture; + } + + public static Texture loadTexture(String url) throws IOException { + if (url == null) return null; + return loadTexture(new URL(url).openStream()); + } + +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/YggdrasilServer.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/YggdrasilServer.java index 9f839dbdf..2da246cda 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/YggdrasilServer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/YggdrasilServer.java @@ -28,15 +28,7 @@ import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter; import org.jackhuang.hmcl.util.io.HttpServer; import org.jackhuang.hmcl.util.io.IOUtils; -import javax.imageio.IIOException; -import javax.imageio.ImageIO; -import java.awt.image.BufferedImage; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.InputStream; -import java.math.BigInteger; -import java.net.URL; import java.security.*; import java.util.*; import java.util.regex.Pattern; @@ -50,7 +42,6 @@ import static org.jackhuang.hmcl.util.Pair.pair; public class YggdrasilServer extends HttpServer { - private final Map textures = new HashMap<>(); private final Map charactersByUuid = new HashMap<>(); private final Map charactersByName = new HashMap<>(); @@ -125,8 +116,8 @@ public class YggdrasilServer extends HttpServer { private Response texture(Request request) { String hash = request.getPathVariables().group("hash"); - if (textures.containsKey(hash)) { - Texture texture = textures.get(hash); + if (Texture.hasTexture(hash)) { + Texture texture = Texture.getTexture(hash); Response response = newFixedLengthResponse(Response.Status.OK, "image/png", texture.getInputStream(), texture.getLength()); response.addHeader("Etag", String.format("\"%s\"", hash)); response.addHeader("Cache-Control", "max-age=2592000, public"); @@ -144,80 +135,6 @@ public class YggdrasilServer extends HttpServer { return Optional.ofNullable(charactersByName.get(uuid)); } - private static String computeTextureHash(BufferedImage img) { - MessageDigest digest; - try { - digest = MessageDigest.getInstance("SHA-256"); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException(e); - } - int width = img.getWidth(); - int height = img.getHeight(); - byte[] buf = new byte[4096]; - - putInt(buf, 0, width); - putInt(buf, 4, height); - int pos = 8; - for (int x = 0; x < width; x++) { - for (int y = 0; y < height; y++) { - putInt(buf, pos, img.getRGB(x, y)); - if (buf[pos + 0] == 0) { - buf[pos + 1] = buf[pos + 2] = buf[pos + 3] = 0; - } - pos += 4; - if (pos == buf.length) { - pos = 0; - digest.update(buf, 0, buf.length); - } - } - } - if (pos > 0) { - digest.update(buf, 0, pos); - } - - byte[] sha256 = digest.digest(); - return String.format("%0" + (sha256.length << 1) + "x", new BigInteger(1, sha256)); - } - - private static void putInt(byte[] array, int offset, int x) { - array[offset + 0] = (byte) (x >> 24 & 0xff); - array[offset + 1] = (byte) (x >> 16 & 0xff); - array[offset + 2] = (byte) (x >> 8 & 0xff); - array[offset + 3] = (byte) (x >> 0 & 0xff); - } - - private Texture loadTexture(InputStream in) throws IOException { - if (in == null) return null; - BufferedImage img = ImageIO.read(in); - if (img == null) { - throw new IIOException("No image found"); - } - - String hash = computeTextureHash(img); - - Texture existent = textures.get(hash); - if (existent != null) { - return existent; - } - - String url = String.format("http://localhost:%d/textures/%s", getListeningPort(), hash); - ByteArrayOutputStream buf = new ByteArrayOutputStream(); - ImageIO.write(img, "png", buf); - Texture texture = new Texture(hash, buf.toByteArray(), url); - - existent = textures.putIfAbsent(hash, texture); - - if (existent != null) { - return existent; - } - return texture; - } - - public Texture loadTexture(String url) throws IOException { - if (url == null) return null; - return loadTexture(new URL(url).openStream()); - } - public void addCharacter(Character character) { charactersByUuid.put(character.getUUID(), character); charactersByName.put(character.getName(), character); @@ -267,7 +184,7 @@ public class YggdrasilServer extends HttpServer { Map realTextures = new HashMap<>(); for (Map.Entry textureEntry : textures.entrySet()) { if (textureEntry.getValue() == null) continue; - realTextures.put(textureEntry.getKey().name(), mapOf(pair("url", rootUrl + "/textures/" + textureEntry.getValue().hash))); + realTextures.put(textureEntry.getKey().name(), mapOf(pair("url", rootUrl + "/textures/" + textureEntry.getValue().getHash()))); } Map textureResponse = mapOf( @@ -289,30 +206,6 @@ public class YggdrasilServer extends HttpServer { } } - private static class Texture { - private final String hash; - private final byte[] data; - private final String url; - - public Texture(String hash, byte[] data, String url) { - this.hash = requireNonNull(hash); - this.data = requireNonNull(data); - this.url = requireNonNull(url); - } - - public String getUrl() { - return url; - } - - public InputStream getInputStream() { - return new ByteArrayInputStream(data); - } - - public int getLength() { - return data.length; - } - } - // === Signature === private static final KeyPair keyPair = KeyUtils.generateKey(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/CacheRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/CacheRepository.java index b5a700a9b..b5c15f9e6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/CacheRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/CacheRepository.java @@ -17,10 +17,7 @@ */ package org.jackhuang.hmcl.util; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.OutputStream; -import java.io.RandomAccessFile; +import java.io.*; import java.net.URLConnection; import java.nio.channels.Channels; import java.nio.channels.FileChannel; @@ -35,6 +32,7 @@ import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.function.BiFunction; +import java.util.function.Supplier; import java.util.logging.Level; import java.util.stream.Stream; @@ -190,14 +188,34 @@ public class CacheRepository { // conn.setRequestProperty("If-Modified-Since", eTagItem.getRemoteLastModified()); } - public synchronized void cacheRemoteFile(Path downloaded, URLConnection conn) throws IOException { + public void cacheRemoteFile(Path downloaded, URLConnection conn) throws IOException { + cacheData(() -> { + String hash = Hex.encodeHex(DigestUtils.digest(SHA1, downloaded)); + Path cached = cacheFile(downloaded, SHA1, hash); + return new CacheResult(hash, cached); + }, conn); + } + + public void cacheText(String text, URLConnection conn) throws IOException { + cacheBytes(text.getBytes(UTF_8), conn); + } + + public void cacheBytes(byte[] bytes, URLConnection conn) throws IOException { + cacheData(() -> { + String hash = Hex.encodeHex(DigestUtils.digest(SHA1, bytes)); + Path cached = getFile(SHA1, hash); + FileUtils.writeBytes(cached.toFile(), bytes); + return new CacheResult(hash, cached); + }, conn); + } + + public synchronized void cacheData(ExceptionalSupplier cacheSupplier, URLConnection conn) throws IOException { String eTag = conn.getHeaderField("ETag"); if (eTag == null) return; String url = conn.getURL().toString(); String lastModified = conn.getHeaderField("Last-Modified"); - String hash = Hex.encodeHex(DigestUtils.digest(SHA1, downloaded)); - Path cached = cacheFile(downloaded, SHA1, hash); - ETagItem eTagItem = new ETagItem(url, eTag, hash, Files.getLastModifiedTime(cached).toMillis(), lastModified); + CacheResult cacheResult = cacheSupplier.get(); + ETagItem eTagItem = new ETagItem(url, eTag, cacheResult.hash, Files.getLastModifiedTime(cacheResult.cachedFile).toMillis(), lastModified); Lock writeLock = lock.writeLock(); writeLock.lock(); try { @@ -208,22 +226,13 @@ public class CacheRepository { } } - public synchronized void cacheText(String text, URLConnection conn) throws IOException { - String eTag = conn.getHeaderField("ETag"); - if (eTag == null) return; - String url = conn.getURL().toString(); - String lastModified = conn.getHeaderField("Last-Modified"); - String hash = Hex.encodeHex(DigestUtils.digest(SHA1, text)); - Path cached = getFile(SHA1, hash); - FileUtils.writeText(cached.toFile(), text); - ETagItem eTagItem = new ETagItem(url, eTag, hash, Files.getLastModifiedTime(cached).toMillis(), lastModified); - Lock writeLock = lock.writeLock(); - writeLock.lock(); - try { - index.compute(eTagItem.url, updateEntity(eTagItem)); - saveETagIndex(); - } finally { - writeLock.unlock(); + private static class CacheResult { + public String hash; + public Path cachedFile; + + public CacheResult(String hash, Path cachedFile) { + this.hash = hash; + this.cachedFile = cachedFile; } }