feat(skin): WIP: download skin from CSL server.

This commit is contained in:
huanghongxun
2021-09-23 23:41:45 +08:00
parent 79e4aa5aaf
commit 01893b053d
6 changed files with 416 additions and 139 deletions

View File

@@ -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<TextureType, Texture> textures;
protected OfflineAccount(AuthlibInjectorArtifactProvider downloader, String username, UUID uuid, String skin, String cape) {
protected OfflineAccount(AuthlibInjectorArtifactProvider downloader, String username, UUID uuid, Map<TextureType, Texture> 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");

View File

@@ -0,0 +1,202 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
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<Texture> 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<InputStream> {
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;
}
}
}
}

View File

@@ -0,0 +1,140 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
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<String, Texture> 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());
}
}

View File

@@ -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<String, Texture> textures = new HashMap<>();
private final Map<UUID, Character> charactersByUuid = new HashMap<>();
private final Map<String, Character> 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<String, Object> realTextures = new HashMap<>();
for (Map.Entry<TextureType, Texture> 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<String, Object> 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();

View File

@@ -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<CacheResult, IOException> 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;
}
}