Merge branch 'skin-refactor' into javafx

This commit is contained in:
huanghongxun
2019-02-06 22:53:38 +08:00
40 changed files with 1033 additions and 608 deletions

View File

@@ -1,194 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2019 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.game;
import javafx.embed.swing.SwingFXUtils;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.yggdrasil.GameProfile;
import org.jackhuang.hmcl.auth.yggdrasil.Texture;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Scheduler;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.DialogController;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.File;
import java.util.*;
public final class AccountHelper {
private AccountHelper() {}
public static final File SKIN_DIR = Metadata.HMCL_DIRECTORY.resolve("skins").toFile();
public static void loadSkins() {
for (Account account : Accounts.getAccounts()) {
if (account instanceof YggdrasilAccount) {
new SkinLoadTask((YggdrasilAccount) account, false).start();
}
}
}
public static Task loadSkinAsync(YggdrasilAccount account) {
return new SkinLoadTask(account, false);
}
public static Task refreshSkinAsync(YggdrasilAccount account) {
return new SkinLoadTask(account, true);
}
private static File getSkinFile(UUID uuid) {
return new File(SKIN_DIR, uuid + ".png");
}
public static Image getSkin(YggdrasilAccount account) {
return getSkin(account, 1);
}
public static Image getSkin(YggdrasilAccount account, double scaleRatio) {
UUID uuid = account.getUUID();
if (uuid == null)
return getSteveSkin(scaleRatio);
File file = getSkinFile(uuid);
if (file.exists()) {
Image original = new Image("file:" + file.getAbsolutePath());
if (original.isError())
return getDefaultSkin(uuid, scaleRatio);
return new Image("file:" + file.getAbsolutePath(),
original.getWidth() * scaleRatio,
original.getHeight() * scaleRatio,
false, false);
}
return getDefaultSkin(uuid, scaleRatio);
}
public static Image getSkinImmediately(YggdrasilAccount account, GameProfile profile, double scaleRatio) throws Exception {
File file = getSkinFile(profile.getId());
downloadSkin(account, profile, true);
if (!file.exists())
return getDefaultSkin(profile.getId(), scaleRatio);
String url = "file:" + file.getAbsolutePath();
return scale(url, scaleRatio);
}
public static Image getHead(Image skin, int scaleRatio) {
final int size = 8 * scaleRatio;
final int faceOffset = (int) Math.round(scaleRatio * 4d / 9d);
BufferedImage image = SwingFXUtils.fromFXImage(skin, null);
BufferedImage head = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = head.createGraphics();
g2d.drawImage(image, faceOffset, faceOffset, size - faceOffset, size - faceOffset,
size, size, size + size, size + size, null);
if (image.getHeight() > 32) {
g2d.drawImage(image, 0, 0, size, size,
40 * scaleRatio, 8 * scaleRatio, 48 * scaleRatio, 16 * scaleRatio, null);
}
return SwingFXUtils.toFXImage(head, null);
}
private static class SkinLoadTask extends Task {
private final YggdrasilAccount account;
private final boolean refresh;
private final List<Task> dependencies = new LinkedList<>();
public SkinLoadTask(YggdrasilAccount account, boolean refresh) {
this.account = account;
this.refresh = refresh;
}
@Override
public Scheduler getScheduler() {
return Schedulers.io();
}
@Override
public Collection<Task> getDependencies() {
return dependencies;
}
@Override
public void execute() throws Exception {
if (!account.isLoggedIn() && (account.getCharacter() == null || refresh))
DialogController.logIn(account);
downloadSkin(account, refresh);
}
}
private static void downloadSkin(YggdrasilAccount account, GameProfile profile, boolean refresh) throws Exception {
account.clearCache();
File file = getSkinFile(profile.getId());
if (!refresh && file.exists())
return;
Optional<Texture> texture = account.getSkin(profile);
if (!texture.isPresent()) return;
String url = texture.get().getUrl();
new FileDownloadTask(NetworkUtils.toURL(url), file).run();
}
private static void downloadSkin(YggdrasilAccount account, boolean refresh) throws Exception {
account.clearCache();
if (account.getCharacter() == null) return;
File file = getSkinFile(account.getUUID());
if (!refresh && file.exists()) {
Image original = new Image("file:" + file.getAbsolutePath());
if (!original.isError())
return;
}
Optional<Texture> texture = account.getSkin();
if (!texture.isPresent()) return;
String url = texture.get().getUrl();
new FileDownloadTask(NetworkUtils.toURL(url), file).run();
}
public static Image scale(String url, double scaleRatio) {
Image origin = new Image(url);
return new Image(url,
origin.getWidth() * scaleRatio,
origin.getHeight() * scaleRatio,
false, false);
}
public static Image getSteveSkin(double scaleRatio) {
return scale("/assets/img/steve.png", scaleRatio);
}
public static Image getAlexSkin(double scaleRatio) {
return scale("/assets/img/alex.png", scaleRatio);
}
public static Image getDefaultSkin(UUID uuid, double scaleRatio) {
int type = uuid.hashCode() & 1;
if (type == 1)
return getAlexSkin(scaleRatio);
else
return getSteveSkin(scaleRatio);
}
}

View File

@@ -0,0 +1,209 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2019 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.game;
import static java.util.Collections.singletonMap;
import static org.jackhuang.hmcl.util.Lang.threadPool;
import static org.jackhuang.hmcl.util.Logging.LOG;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import javax.imageio.ImageIO;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.ServerResponseMalformedException;
import org.jackhuang.hmcl.auth.yggdrasil.Texture;
import org.jackhuang.hmcl.auth.yggdrasil.TextureModel;
import org.jackhuang.hmcl.auth.yggdrasil.TextureType;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.util.javafx.MultiStepBinding;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
import javafx.embed.swing.SwingFXUtils;
import javafx.scene.image.Image;
/**
* @author yushijinhun
*/
public final class TexturesLoader {
private TexturesLoader() {
}
// ==== Texture Loading ====
public static class LoadedTexture {
private final BufferedImage image;
private final Map<String, String> metadata;
public LoadedTexture(BufferedImage image, Map<String, String> metadata) {
this.image = image;
this.metadata = metadata;
}
public BufferedImage getImage() {
return image;
}
public Map<String, String> getMetadata() {
return metadata;
}
}
private static final ThreadPoolExecutor POOL = threadPool("TexturesDownload", true, 2, 10, TimeUnit.SECONDS);
private static final Path TEXTURES_DIR = Metadata.MINECRAFT_DIRECTORY.resolve("assets").resolve("skins");
private static Path getTexturePath(Texture texture) {
String url = texture.getUrl();
int slash = url.lastIndexOf('/');
int dot = url.lastIndexOf('.');
if (dot < slash) {
dot = url.length();
}
String hash = url.substring(slash + 1, dot);
String prefix = hash.length() > 2 ? hash.substring(0, 2) : "xx";
return TEXTURES_DIR.resolve(prefix).resolve(hash);
}
public static LoadedTexture loadTexture(Texture texture) throws IOException {
Path file = getTexturePath(texture);
if (!Files.isRegularFile(file)) {
// download it
try {
new FileDownloadTask(new URL(texture.getUrl()), file.toFile()).run();
LOG.info("Texture downloaded: " + texture.getUrl());
} catch (Exception e) {
if (Files.isRegularFile(file)) {
// concurrency conflict?
LOG.log(Level.WARNING, "Failed to download texture " + texture.getUrl() + ", but the file is available", e);
} else {
throw new IOException("Failed to download texture " + texture.getUrl());
}
}
}
BufferedImage img;
try (InputStream in = Files.newInputStream(file)) {
img = ImageIO.read(in);
}
return new LoadedTexture(img, texture.getMetadata());
}
// ====
// ==== Skins ====
private final static Map<TextureModel, LoadedTexture> DEFAULT_SKINS = new EnumMap<>(TextureModel.class);
static {
loadDefaultSkin("/assets/img/steve.png", TextureModel.STEVE);
loadDefaultSkin("/assets/img/alex.png", TextureModel.ALEX);
}
private static void loadDefaultSkin(String path, TextureModel model) {
try (InputStream in = TexturesLoader.class.getResourceAsStream(path)) {
DEFAULT_SKINS.put(model, new LoadedTexture(ImageIO.read(in), singletonMap("model", model.modelName)));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
public static LoadedTexture getDefaultSkin(TextureModel model) {
return DEFAULT_SKINS.get(model);
}
public static ObjectBinding<LoadedTexture> skinBinding(YggdrasilService service, UUID uuid) {
LoadedTexture uuidFallback = getDefaultSkin(TextureModel.detectUUID(uuid));
return MultiStepBinding.of(service.getProfileRepository().binding(uuid))
.map(profile -> profile
.flatMap(it -> {
try {
return YggdrasilService.getTextures(it);
} catch (ServerResponseMalformedException e) {
LOG.log(Level.WARNING, "Failed to parse texture payload", e);
return Optional.empty();
}
})
.flatMap(it -> Optional.ofNullable(it.get(TextureType.SKIN))))
.asyncMap(it -> {
if (it.isPresent()) {
Texture texture = it.get();
try {
return loadTexture(texture);
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to load texture " + texture.getUrl() + ", using fallback texture", e);
return getDefaultSkin(TextureModel.detectModelName(texture.getMetadata()));
}
} else {
return uuidFallback;
}
}, uuidFallback, POOL);
}
// ====
// ==== Avatar ====
public static BufferedImage toAvatar(BufferedImage skin, int size) {
BufferedImage avatar = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = avatar.createGraphics();
int scale = skin.getWidth() / 64;
int faceOffset = (int) Math.round(size / 18.0);
g.drawImage(skin,
faceOffset, faceOffset, size - faceOffset, size - faceOffset,
8 * scale, 8 * scale, 16 * scale, 16 * scale,
null);
if (skin.getWidth() == skin.getHeight()) {
g.drawImage(skin,
0, 0, size, size,
40 * scale, 8 * scale, 48 * scale, 16 * scale, null);
}
g.dispose();
return avatar;
}
public static ObjectBinding<Image> fxAvatarBinding(YggdrasilService service, UUID uuid, int size) {
return MultiStepBinding.of(skinBinding(service, uuid))
.map(it -> toAvatar(it.image, size))
.map(img -> SwingFXUtils.toFXImage(img, null));
}
public static ObjectBinding<Image> fxAvatarBinding(Account account, int size) {
if (account instanceof YggdrasilAccount) {
return fxAvatarBinding(((YggdrasilAccount) account).getYggdrasilService(), account.getUUID(), size);
} else {
return Bindings.createObjectBinding(
() -> SwingFXUtils.toFXImage(toAvatar(getDefaultSkin(TextureModel.detectUUID(account.getUUID())).image, size), null));
}
}
// ====
}

View File

@@ -35,7 +35,6 @@ import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.auth.authlibinjector.SimpleAuthlibInjectorArtifactProvider;
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
import org.jackhuang.hmcl.auth.offline.OfflineAccountFactory;
import org.jackhuang.hmcl.auth.yggdrasil.MojangYggdrasilProvider;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccountFactory;
import org.jackhuang.hmcl.task.Schedulers;
@@ -63,7 +62,7 @@ public final class Accounts {
private Accounts() {}
public static final OfflineAccountFactory FACTORY_OFFLINE = OfflineAccountFactory.INSTANCE;
public static final YggdrasilAccountFactory FACTORY_YGGDRASIL = new YggdrasilAccountFactory(MojangYggdrasilProvider.INSTANCE);
public static final YggdrasilAccountFactory FACTORY_MOJANG = YggdrasilAccountFactory.MOJANG;
public static final AuthlibInjectorAccountFactory FACTORY_AUTHLIB_INJECTOR = new AuthlibInjectorAccountFactory(createAuthlibInjectorArtifactProvider(), Accounts::getOrCreateAuthlibInjectorServer);
// ==== login type / account factory mapping ====
@@ -71,7 +70,7 @@ public final class Accounts {
private static final Map<AccountFactory<?>, String> factory2type = new HashMap<>();
static {
type2factory.put("offline", FACTORY_OFFLINE);
type2factory.put("yggdrasil", FACTORY_YGGDRASIL);
type2factory.put("yggdrasil", FACTORY_MOJANG);
type2factory.put("authlibInjector", FACTORY_AUTHLIB_INJECTOR);
type2factory.forEach((type, factory) -> factory2type.put(factory, type));
@@ -94,7 +93,7 @@ public final class Accounts {
else if (account instanceof AuthlibInjectorAccount)
return FACTORY_AUTHLIB_INJECTOR;
else if (account instanceof YggdrasilAccount)
return FACTORY_YGGDRASIL;
return FACTORY_MOJANG;
else
throw new IllegalArgumentException("Failed to determine account type: " + account);
}
@@ -279,7 +278,7 @@ public final class Accounts {
// ==== Login type name i18n ===
private static Map<AccountFactory<?>, String> unlocalizedLoginTypeNames = mapOf(
pair(Accounts.FACTORY_OFFLINE, "account.methods.offline"),
pair(Accounts.FACTORY_YGGDRASIL, "account.methods.yggdrasil"),
pair(Accounts.FACTORY_MOJANG, "account.methods.yggdrasil"),
pair(Accounts.FACTORY_AUTHLIB_INJECTOR, "account.methods.authlib_injector"));
public static String getLocalizedLoginTypeName(AccountFactory<?> factory) {

View File

@@ -17,15 +17,15 @@
*/
package org.jackhuang.hmcl.ui.account;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount;
import org.jackhuang.hmcl.game.AccountHelper;
import org.jackhuang.hmcl.game.TexturesLoader;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
@@ -38,23 +38,15 @@ public class AccountAdvancedListItem extends AdvancedListItem {
protected void invalidated() {
Account account = get();
if (account == null) {
titleProperty().unbind();
setTitle(i18n("account.missing"));
setSubtitle(i18n("account.missing.add"));
imageProperty().unbind();
setImage(new Image("/assets/img/craft_table.png"));
} else {
setTitle(account.getCharacter());
titleProperty().bind(Bindings.createStringBinding(account::getCharacter, account));
setSubtitle(accountSubtitle(account));
final int scaleRatio = 4;
Image defaultSkin = AccountHelper.getDefaultSkin(account.getUUID(), scaleRatio);
setImage(AccountHelper.getHead(defaultSkin, scaleRatio));
if (account instanceof YggdrasilAccount) {
AccountHelper.loadSkinAsync((YggdrasilAccount) account).subscribe(Schedulers.javafx(), () -> {
Image image = AccountHelper.getSkin((YggdrasilAccount) account, scaleRatio);
setImage(AccountHelper.getHead(image, scaleRatio));
});
}
imageProperty().bind(TexturesLoader.fxAvatarBinding(account, 32));
}
}
};

View File

@@ -17,21 +17,28 @@
*/
package org.jackhuang.hmcl.ui.account;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.StringBinding;
import javafx.beans.property.*;
import javafx.scene.control.RadioButton;
import javafx.scene.control.Skin;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.AuthenticationException;
import org.jackhuang.hmcl.auth.CredentialExpiredException;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount;
import org.jackhuang.hmcl.game.AccountHelper;
import org.jackhuang.hmcl.game.TexturesLoader;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.DialogController;
import static org.jackhuang.hmcl.util.Lang.thread;
import static org.jackhuang.hmcl.util.Logging.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import java.util.logging.Level;
public class AccountListItem extends RadioButton {
private final Account account;
@@ -44,23 +51,24 @@ public class AccountListItem extends RadioButton {
getStyleClass().clear();
setUserData(account);
StringBuilder subtitleString = new StringBuilder(Accounts.getLocalizedLoginTypeName(Accounts.getAccountFactory(account)));
String loginTypeName = Accounts.getLocalizedLoginTypeName(Accounts.getAccountFactory(account));
if (account instanceof AuthlibInjectorAccount) {
AuthlibInjectorServer server = ((AuthlibInjectorAccount) account).getServer();
subtitleString.append(", ").append(i18n("account.injector.server")).append(": ").append(server.getName());
subtitle.bind(Bindings.concat(
loginTypeName, ", ", i18n("account.injector.server"), ": ",
Bindings.createStringBinding(server::getName, server)));
} else {
subtitle.set(loginTypeName);
}
if (account instanceof OfflineAccount)
title.set(account.getCharacter());
else
title.set(account.getUsername() + " - " + account.getCharacter());
subtitle.set(subtitleString.toString());
StringBinding characterName = Bindings.createStringBinding(account::getCharacter, account);
if (account instanceof OfflineAccount) {
title.bind(characterName);
} else {
title.bind(Bindings.concat(account.getUsername(), " - ", characterName));
}
final int scaleRatio = 4;
Image image = account instanceof YggdrasilAccount ?
AccountHelper.getSkin((YggdrasilAccount) account, scaleRatio) :
AccountHelper.getDefaultSkin(account.getUUID(), scaleRatio);
this.image.set(AccountHelper.getHead(image, scaleRatio));
image.bind(TexturesLoader.fxAvatarBinding(account, 32));
}
@Override
@@ -69,19 +77,20 @@ public class AccountListItem extends RadioButton {
}
public void refresh() {
if (account instanceof YggdrasilAccount) {
// progressBar.setVisible(true);
AccountHelper.refreshSkinAsync((YggdrasilAccount) account)
.finalized(Schedulers.javafx(), (variables, isDependentsSucceeded) -> {
// progressBar.setVisible(false);
if (isDependentsSucceeded) {
final int scaleRatio = 4;
Image image = AccountHelper.getSkin((YggdrasilAccount) account, scaleRatio);
this.image.set(AccountHelper.getHead(image, scaleRatio));
}
}).start();
}
account.clearCache();
thread(() -> {
try {
account.logIn();
} catch (CredentialExpiredException e) {
try {
DialogController.logIn(account);
} catch (Exception e1) {
LOG.log(Level.WARNING, "Failed to refresh " + account + " with password", e1);
}
} catch (AuthenticationException e) {
LOG.log(Level.WARNING, "Failed to refresh " + account + " with token", e);
}
});
}
public void remove() {

View File

@@ -37,8 +37,7 @@ public class AccountLoginPane extends StackPane {
private final Consumer<AuthInfo> success;
private final Runnable failed;
@FXML
private Label lblUsername;
@FXML private Label lblUsername;
@FXML private JFXPasswordField txtPassword;
@FXML private Label lblCreationWarning;
@FXML private JFXProgressBar progressBar;

View File

@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.ui.account;
import com.jfoenix.concurrency.JFXUtilities;
import com.jfoenix.controls.*;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ListProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
@@ -29,7 +30,6 @@ import javafx.fxml.FXML;
import javafx.geometry.Pos;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
@@ -39,23 +39,20 @@ import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.auth.yggdrasil.GameProfile;
import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount;
import org.jackhuang.hmcl.game.AccountHelper;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService;
import org.jackhuang.hmcl.game.TexturesLoader;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.Logging;
import org.jackhuang.hmcl.util.javafx.MultiStepBinding;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.logging.Level;
import static java.util.Collections.emptyList;
import static java.util.Collections.unmodifiableList;
import static java.util.Objects.requireNonNull;
@@ -89,7 +86,7 @@ public class AddAccountPane extends StackPane {
cboServers.getItems().addListener(onInvalidating(this::selectDefaultServer));
selectDefaultServer();
cboType.getItems().setAll(Accounts.FACTORY_OFFLINE, Accounts.FACTORY_YGGDRASIL, Accounts.FACTORY_AUTHLIB_INJECTOR);
cboType.getItems().setAll(Accounts.FACTORY_OFFLINE, Accounts.FACTORY_MOJANG, Accounts.FACTORY_AUTHLIB_INJECTOR);
cboType.setConverter(stringConverter(Accounts::getLocalizedLoginTypeName));
// try selecting the preferred login type
cboType.getSelectionModel().select(
@@ -250,7 +247,7 @@ public class AddAccountPane extends StackPane {
private final CountDownLatch latch = new CountDownLatch(1);
private GameProfile selectedProfile = null;
{
public Selector() {
setStyle("-fx-padding: 8px;");
cancel.setText(i18n("button.cancel"));
@@ -268,45 +265,33 @@ public class AddAccountPane extends StackPane {
}
@Override
public GameProfile select(Account account, List<GameProfile> names) throws NoSelectedCharacterException {
if (!(account instanceof YggdrasilAccount))
return CharacterSelector.DEFAULT.select(account, names);
YggdrasilAccount yggdrasilAccount = (YggdrasilAccount) account;
public GameProfile select(YggdrasilService service, List<GameProfile> profiles) throws NoSelectedCharacterException {
Platform.runLater(() -> {
for (GameProfile profile : profiles) {
ImageView portraitView = new ImageView();
portraitView.setSmooth(false);
portraitView.imageProperty().bind(TexturesLoader.fxAvatarBinding(service, profile.getId(), 32));
FXUtils.limitSize(portraitView, 32, 32);
for (GameProfile profile : names) {
Image image;
final int scaleRatio = 4;
try {
image = AccountHelper.getSkinImmediately(yggdrasilAccount, profile, scaleRatio);
} catch (Exception e) {
Logging.LOG.log(Level.WARNING, "Failed to get skin for " + profile.getName(), e);
image = AccountHelper.getDefaultSkin(profile.getId(), scaleRatio);
IconedItem accountItem = new IconedItem(portraitView, profile.getName());
accountItem.setOnMouseClicked(e -> {
selectedProfile = profile;
latch.countDown();
});
listBox.add(accountItem);
}
ImageView portraitView = new ImageView();
portraitView.setSmooth(false);
portraitView.setImage(AccountHelper.getHead(image, scaleRatio));
FXUtils.limitSize(portraitView, 32, 32);
IconedItem accountItem = new IconedItem(portraitView, profile.getName());
accountItem.setOnMouseClicked(e -> {
selectedProfile = profile;
latch.countDown();
});
listBox.add(accountItem);
}
JFXUtilities.runInFX(() -> Controllers.dialog(this));
Controllers.dialog(this);
});
try {
latch.await();
if (selectedProfile == null)
throw new NoSelectedCharacterException(account);
throw new NoSelectedCharacterException();
return selectedProfile;
} catch (InterruptedException ignore) {
throw new NoSelectedCharacterException(account);
throw new NoSelectedCharacterException();
} finally {
JFXUtilities.runInFX(() -> Selector.this.fireEvent(new DialogCloseEvent()));
}
@@ -334,6 +319,8 @@ public class AddAccountPane extends StackPane {
return exception.getMessage();
} else if (exception instanceof AuthlibInjectorDownloadException) {
return i18n("account.failed.injector_download_failure");
} else if (exception instanceof CharacterDeletedException) {
return i18n("account.failed.character_deleted");
} else if (exception.getClass() == AuthenticationException.class) {
return exception.getLocalizedMessage();
} else {

View File

@@ -19,6 +19,8 @@ package org.jackhuang.hmcl.ui.account;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.effects.JFXDepthManager;
import javafx.beans.binding.Bindings;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
@@ -33,17 +35,17 @@ public final class AuthlibInjectorServerItem extends BorderPane {
private final AuthlibInjectorServer server;
private final Label lblServerName = new Label();
private final Label lblServerIp = new Label();
private final Label lblServerUrl = new Label();
public AuthlibInjectorServerItem(AuthlibInjectorServer server, Consumer<AuthlibInjectorServerItem> deleteCallback) {
this.server = server;
lblServerName.setStyle("-fx-font-size: 15;");
lblServerIp.setStyle("-fx-font-size: 10;");
lblServerUrl.setStyle("-fx-font-size: 10;");
VBox center = new VBox();
BorderPane.setAlignment(center, Pos.CENTER);
center.getChildren().addAll(lblServerName, lblServerIp);
center.getChildren().addAll(lblServerName, lblServerUrl);
setCenter(center);
JFXButton right = new JFXButton();
@@ -55,8 +57,8 @@ public final class AuthlibInjectorServerItem extends BorderPane {
setStyle("-fx-background-radius: 2; -fx-background-color: white; -fx-padding: 8;");
JFXDepthManager.setDepth(this, 1);
lblServerName.setText(server.getName());
lblServerIp.setText(server.getUrl());
lblServerName.textProperty().bind(Bindings.createStringBinding(server::getName, server));
lblServerUrl.setText(server.getUrl());
}
public AuthlibInjectorServer getServer() {

View File

@@ -36,6 +36,7 @@ account.character=character
account.choose=Choose a character
account.create=Create a new account
account.email=Email
account.failed.character_deleted=The character has been deleted.
account.failed.connect_authentication_server=Cannot connect to the authentication server. Check your network.
account.failed.connect_injector_server=Cannot connect to the authentication server. Check your network and ensure the URL is correct.
account.failed.injector_download_failure=Failed to download authlib-injector. Check your network and try switching to another download source.

View File

@@ -35,6 +35,7 @@ account.character=角色
account.choose=選擇一個角色
account.create=建立帳戶
account.email=電子信箱
account.failed.character_deleted=此角色已被刪除
account.failed.connect_authentication_server=無法連接認證伺服器,可能是網路問題
account.failed.connect_injector_server=無法連接認證伺服器,可能是網路故障或 URL 輸入錯誤
account.failed.injector_download_failure=無法下載 authlib-injector請檢查網路或嘗試切換下載源

View File

@@ -35,6 +35,7 @@ account.character=角色
account.choose=选择一个角色
account.create=新建账户
account.email=邮箱
account.failed.character_deleted=此角色已被删除
account.failed.connect_authentication_server=无法连接认证服务器,可能是网络问题
account.failed.connect_injector_server=无法连接认证服务器,可能是网络故障或 URL 输入错误
account.failed.injector_download_failure=无法下载 authlib-injector请检查网络或尝试切换下载源

View File

@@ -69,7 +69,8 @@ public abstract class Account implements Observable {
public abstract Map<Object, Object> toStorage();
public abstract void clearCache();
public void clearCache() {
}
private ObservableHelper helper = new ObservableHelper(this);

View File

@@ -17,27 +17,10 @@
*/
package org.jackhuang.hmcl.auth;
import org.jackhuang.hmcl.auth.yggdrasil.GameProfile;
import java.util.List;
import java.util.UUID;
/**
* Select character by name.
* Thrown when a previously existing character cannot be found.
*/
public class SpecificCharacterSelector implements CharacterSelector {
private UUID uuid;
/**
* Constructor.
* @param uuid character's uuid.
*/
public SpecificCharacterSelector(UUID uuid) {
this.uuid = uuid;
}
@Override
public GameProfile select(Account account, List<GameProfile> names) throws NoSelectedCharacterException {
return names.stream().filter(profile -> profile.getId().equals(uuid)).findAny().orElseThrow(() -> new NoSelectedCharacterException(account));
public final class CharacterDeletedException extends AuthenticationException {
public CharacterDeletedException() {
}
}

View File

@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.auth;
import org.jackhuang.hmcl.auth.yggdrasil.GameProfile;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService;
import java.util.List;
@@ -33,7 +34,6 @@ public interface CharacterSelector {
* @throws NoSelectedCharacterException if cannot select any character may because user close the selection window or cancel the selection.
* @return your choice of game profile.
*/
GameProfile select(Account account, List<GameProfile> names) throws NoSelectedCharacterException;
GameProfile select(YggdrasilService yggdrasilService, List<GameProfile> names) throws NoSelectedCharacterException;
CharacterSelector DEFAULT = (account, names) -> names.stream().findFirst().orElseThrow(() -> new NoSelectedCharacterException(account));
}

View File

@@ -22,13 +22,6 @@ package org.jackhuang.hmcl.auth;
* (A account may hold more than one characters.)
*/
public final class NoCharacterException extends AuthenticationException {
private final Account account;
public NoCharacterException(Account account) {
this.account = account;
}
public Account getAccount() {
return account;
public NoCharacterException() {
}
}

View File

@@ -25,17 +25,6 @@ package org.jackhuang.hmcl.auth;
* @author huangyuhui
*/
public final class NoSelectedCharacterException extends AuthenticationException {
private final Account account;
/**
*
* @param account the error yggdrasil account.
*/
public NoSelectedCharacterException(Account account) {
this.account = account;
}
public Account getAccount() {
return account;
public NoSelectedCharacterException() {
}
}

View File

@@ -22,7 +22,6 @@ import org.jackhuang.hmcl.auth.AuthenticationException;
import org.jackhuang.hmcl.auth.CharacterSelector;
import org.jackhuang.hmcl.auth.ServerDisconnectException;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilSession;
import org.jackhuang.hmcl.game.Arguments;
import org.jackhuang.hmcl.util.ToStringBuilder;
@@ -36,14 +35,19 @@ import java.util.concurrent.ExecutionException;
import static java.nio.charset.StandardCharsets.UTF_8;
public class AuthlibInjectorAccount extends YggdrasilAccount {
private AuthlibInjectorServer server;
private final AuthlibInjectorServer server;
private AuthlibInjectorArtifactProvider downloader;
protected AuthlibInjectorAccount(YggdrasilService service, AuthlibInjectorServer server, AuthlibInjectorArtifactProvider downloader, String username, UUID characterUUID, YggdrasilSession session) {
super(service, username, characterUUID, session);
this.downloader = downloader;
public AuthlibInjectorAccount(AuthlibInjectorServer server, AuthlibInjectorArtifactProvider downloader, String username, String password, CharacterSelector selector) throws AuthenticationException {
super(server.getYggdrasilService(), username, password, selector);
this.server = server;
this.downloader = downloader;
}
public AuthlibInjectorAccount(AuthlibInjectorServer server, AuthlibInjectorArtifactProvider downloader, String username, YggdrasilSession session) {
super(server.getYggdrasilService(), username, session);
this.server = server;
this.downloader = downloader;
}
@Override
@@ -52,8 +56,8 @@ public class AuthlibInjectorAccount extends YggdrasilAccount {
}
@Override
protected AuthInfo logInWithPassword(String password, CharacterSelector selector) throws AuthenticationException {
return inject(() -> super.logInWithPassword(password, selector));
public synchronized AuthInfo logInWithPassword(String password) throws AuthenticationException {
return inject(() -> super.logInWithPassword(password));
}
@Override
@@ -121,6 +125,12 @@ public class AuthlibInjectorAccount extends YggdrasilAccount {
return map;
}
@Override
public void clearCache() {
super.clearCache();
server.invalidateMetadataCache();
}
public AuthlibInjectorServer getServer() {
return server;
}

View File

@@ -20,7 +20,8 @@ package org.jackhuang.hmcl.auth.authlibinjector;
import org.jackhuang.hmcl.auth.AccountFactory;
import org.jackhuang.hmcl.auth.AuthenticationException;
import org.jackhuang.hmcl.auth.CharacterSelector;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService;
import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile;
import org.jackhuang.hmcl.auth.yggdrasil.GameProfile;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilSession;
import java.util.Map;
import java.util.Objects;
@@ -48,10 +49,7 @@ public class AuthlibInjectorAccountFactory extends AccountFactory<AuthlibInjecto
AuthlibInjectorServer server = (AuthlibInjectorServer) additionalData;
AuthlibInjectorAccount account = new AuthlibInjectorAccount(new YggdrasilService(new AuthlibInjectorProvider(server.getUrl())),
server, downloader, username, null, null);
account.logInWithPassword(password, selector);
return account;
return new AuthlibInjectorAccount(server, downloader, username, password, selector);
}
@Override
@@ -67,7 +65,14 @@ public class AuthlibInjectorAccountFactory extends AccountFactory<AuthlibInjecto
AuthlibInjectorServer server = serverLookup.apply(apiRoot);
return new AuthlibInjectorAccount(new YggdrasilService(new AuthlibInjectorProvider(server.getUrl())),
server, downloader, username, session.getSelectedProfile().getId(), session);
tryCast(storage.get("profileProperties"), Map.class).ifPresent(
it -> {
@SuppressWarnings("unchecked")
Map<String, String> properties = it;
GameProfile selected = session.getSelectedProfile();
server.getYggdrasilService().getProfileRepository().put(selected.getId(), new CompleteGameProfile(selected, properties));
});
return new AuthlibInjectorAccount(server, downloader, username, session);
}
}

View File

@@ -56,4 +56,9 @@ public class AuthlibInjectorProvider implements YggdrasilProvider {
public URL getProfilePropertiesURL(UUID uuid) {
return NetworkUtils.toURL(apiRoot + "sessionserver/session/minecraft/profile/" + UUIDTypeAdapter.fromUUID(uuid));
}
@Override
public String toString() {
return apiRoot;
}
}

View File

@@ -35,6 +35,7 @@ import java.util.Map;
import java.util.Optional;
import java.util.logging.Level;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService;
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
import org.jetbrains.annotations.Nullable;
@@ -143,16 +144,22 @@ public class AuthlibInjectorServer implements Observable {
private transient Map<String, String> links = emptyMap();
private transient boolean metadataRefreshed;
private transient ObservableHelper helper = new ObservableHelper(this);
private final transient ObservableHelper helper = new ObservableHelper(this);
private final transient YggdrasilService yggdrasilService;
public AuthlibInjectorServer(String url) {
this.url = url;
this.yggdrasilService = new YggdrasilService(new AuthlibInjectorProvider(url));
}
public String getUrl() {
return url;
}
public YggdrasilService getYggdrasilService() {
return yggdrasilService;
}
public Optional<String> getMetadataResponse() {
return Optional.ofNullable(metadataResponse);
}
@@ -222,6 +229,10 @@ public class AuthlibInjectorServer implements Observable {
}
}
public void invalidateMetadataCache() {
metadataRefreshed = false;
}
@Override
public int hashCode() {
return url.hashCode();

View File

@@ -25,10 +25,10 @@ import org.jackhuang.hmcl.util.ToStringBuilder;
import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import static java.util.Objects.requireNonNull;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
@@ -41,15 +41,13 @@ public class OfflineAccount extends Account {
private final String username;
private final UUID uuid;
OfflineAccount(String username, UUID uuid) {
Objects.requireNonNull(username);
Objects.requireNonNull(uuid);
protected OfflineAccount(String username, UUID uuid) {
this.username = requireNonNull(username);
this.uuid = requireNonNull(uuid);
this.username = username;
this.uuid = uuid;
if (StringUtils.isBlank(username))
if (StringUtils.isBlank(username)) {
throw new IllegalArgumentException("Username cannot be blank");
}
}
@Override
@@ -68,10 +66,7 @@ public class OfflineAccount extends Account {
}
@Override
public AuthInfo logIn() throws AuthenticationException {
if (StringUtils.isBlank(username))
throw new AuthenticationException("Username cannot be empty");
public AuthInfo logIn() {
return new AuthInfo(username, uuid, UUIDTypeAdapter.fromUUID(UUID.randomUUID()), "{}");
}
@@ -82,7 +77,7 @@ public class OfflineAccount extends Account {
@Override
public Optional<AuthInfo> playOffline() {
return Optional.empty();
return Optional.of(logIn());
}
@Override
@@ -93,11 +88,6 @@ public class OfflineAccount extends Account {
);
}
@Override
public void clearCache() {
// Nothing to clear.
}
@Override
public String toString() {
return new ToStringBuilder(this)

View File

@@ -0,0 +1,59 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2019 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.yggdrasil;
import static java.util.Objects.requireNonNull;
import java.util.Map;
import java.util.UUID;
import org.jackhuang.hmcl.util.Immutable;
import com.google.gson.JsonParseException;
import com.google.gson.annotations.JsonAdapter;
/**
* @author yushijinhun
*/
@Immutable
public class CompleteGameProfile extends GameProfile {
@JsonAdapter(PropertyMapSerializer.class)
private final Map<String, String> properties;
public CompleteGameProfile(UUID id, String name, Map<String, String> properties) {
super(id, name);
this.properties = requireNonNull(properties);
}
public CompleteGameProfile(GameProfile profile, Map<String, String> properties) {
this(profile.getId(), profile.getName(), properties);
}
public Map<String, String> getProperties() {
return properties;
}
@Override
public void validate() throws JsonParseException {
super.validate();
if (properties == null)
throw new JsonParseException("Game profile properties cannot be null");
}
}

View File

@@ -17,36 +17,31 @@
*/
package org.jackhuang.hmcl.auth.yggdrasil;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.util.Immutable;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.Validation;
import static java.util.Objects.requireNonNull;
import java.util.UUID;
import org.jackhuang.hmcl.util.Immutable;
import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter;
import org.jackhuang.hmcl.util.gson.Validation;
import com.google.gson.JsonParseException;
import com.google.gson.annotations.JsonAdapter;
/**
*
* @author huangyuhui
*/
@Immutable
public final class GameProfile implements Validation {
public class GameProfile implements Validation {
@JsonAdapter(UUIDTypeAdapter.class)
private final UUID id;
private final String name;
private final PropertyMap properties;
public GameProfile() {
this(null, null);
}
private final String name;
public GameProfile(UUID id, String name) {
this(id, name, new PropertyMap());
}
public GameProfile(UUID id, String name, PropertyMap properties) {
this.id = id;
this.name = name;
this.properties = properties;
this.id = requireNonNull(id);
this.name = requireNonNull(name);
}
public UUID getId() {
@@ -57,18 +52,11 @@ public final class GameProfile implements Validation {
return name;
}
/**
* @return nullable
*/
public PropertyMap getProperties() {
return properties;
}
@Override
public void validate() throws JsonParseException {
if (id == null)
throw new JsonParseException("Game profile id cannot be null or malformed");
if (StringUtils.isBlank(name))
throw new JsonParseException("Game profile name cannot be null or blank");
throw new JsonParseException("Game profile id cannot be null");
if (name == null)
throw new JsonParseException("Game profile name cannot be null");
}
}

View File

@@ -24,7 +24,6 @@ import java.net.URL;
import java.util.UUID;
public class MojangYggdrasilProvider implements YggdrasilProvider {
public static final MojangYggdrasilProvider INSTANCE = new MojangYggdrasilProvider();
@Override
public URL getAuthenticationURL() {
@@ -50,4 +49,9 @@ public class MojangYggdrasilProvider implements YggdrasilProvider {
public URL getProfilePropertiesURL(UUID uuid) {
return NetworkUtils.toURL("https://sessionserver.mojang.com/session/minecraft/profile/" + UUIDTypeAdapter.fromUUID(uuid));
}
@Override
public String toString() {
return "mojang";
}
}

View File

@@ -1,85 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2019 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.yggdrasil;
import com.google.gson.*;
import java.lang.reflect.Type;
import java.util.HashMap;
import java.util.Map;
public final class PropertyMap extends HashMap<String, String> {
public static PropertyMap fromMap(Map<?, ?> map) {
PropertyMap propertyMap = new PropertyMap();
for (Map.Entry<?, ?> entry : map.entrySet()) {
if (entry.getKey() instanceof String && entry.getValue() instanceof String)
propertyMap.put((String) entry.getKey(), (String) entry.getValue());
}
return propertyMap;
}
public static class Serializer implements JsonSerializer<PropertyMap>, JsonDeserializer<PropertyMap> {
public static final Serializer INSTANCE = new Serializer();
private Serializer() {
}
@Override
public PropertyMap deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
PropertyMap result = new PropertyMap();
for (JsonElement element : json.getAsJsonArray())
if (element instanceof JsonObject) {
JsonObject object = (JsonObject) element;
result.put(object.get("name").getAsString(), object.get("value").getAsString());
}
return result;
}
@Override
public JsonElement serialize(PropertyMap src, Type typeOfSrc, JsonSerializationContext context) {
JsonArray result = new JsonArray();
for (Map.Entry<String, String> entry : src.entrySet()) {
JsonObject object = new JsonObject();
object.addProperty("name", entry.getKey());
object.addProperty("value", entry.getValue());
result.add(object);
}
return result;
}
}
public static class LegacySerializer
implements JsonSerializer<PropertyMap> {
public static final LegacySerializer INSTANCE = new LegacySerializer();
@Override
public JsonElement serialize(PropertyMap src, Type typeOfSrc, JsonSerializationContext context) {
JsonObject result = new JsonObject();
for (PropertyMap.Entry<String, String> entry : src.entrySet()) {
JsonArray values = new JsonArray();
values.add(new JsonPrimitive(entry.getValue()));
result.add(entry.getKey(), values);
}
return result;
}
}
}

View File

@@ -0,0 +1,60 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2019 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.yggdrasil;
import static java.util.Collections.unmodifiableMap;
import java.lang.reflect.Type;
import java.util.LinkedHashMap;
import java.util.Map;
import com.google.gson.JsonArray;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
public class PropertyMapSerializer implements JsonSerializer<Map<String, String>>, JsonDeserializer<Map<String, String>> {
@Override
public Map<String, String> deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
Map<String, String> result = new LinkedHashMap<>();
for (JsonElement element : json.getAsJsonArray())
if (element instanceof JsonObject) {
JsonObject object = (JsonObject) element;
result.put(object.get("name").getAsString(), object.get("value").getAsString());
}
return unmodifiableMap(result);
}
@Override
public JsonElement serialize(Map<String, String> src, Type typeOfSrc, JsonSerializationContext context) {
JsonArray result = new JsonArray();
src.forEach((k, v) -> {
JsonObject object = new JsonObject();
object.addProperty("name", k);
object.addProperty("value", v);
result.add(object);
});
return result;
}
}

View File

@@ -40,10 +40,7 @@ public final class Texture {
return url;
}
public String getMetadata(String key) {
if (metadata == null)
return null;
else
return metadata.get(key);
public Map<String, String> getMetadata() {
return metadata;
}
}

View File

@@ -0,0 +1,43 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2019 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.yggdrasil;
import java.util.Map;
import java.util.UUID;
public enum TextureModel {
STEVE("default"), ALEX("slim");
public final String modelName;
private TextureModel(String modelName) {
this.modelName = modelName;
}
public static TextureModel detectModelName(Map<String, String> metadata) {
if (metadata != null && "slim".equals(metadata.get("model"))) {
return ALEX;
} else {
return STEVE;
}
}
public static TextureModel detectUUID(UUID uuid) {
return (uuid.hashCode() & 1) == 1 ? ALEX : STEVE;
}
}

View File

@@ -18,23 +18,31 @@
package org.jackhuang.hmcl.auth.yggdrasil;
import com.google.gson.JsonParseException;
import java.util.Map;
import org.jackhuang.hmcl.util.Immutable;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.Validation;
import org.jetbrains.annotations.Nullable;
/**
*
* @author huang
*/
@Immutable
public final class User implements Validation {
private final String id;
private final PropertyMap properties;
@Nullable
private final Map<String, String> properties;
public User(String id) {
this(id, null);
}
public User(String id, PropertyMap properties) {
public User(String id, Map<String, String> properties) {
this.id = id;
this.properties = properties;
}
@@ -43,7 +51,7 @@ public final class User implements Validation {
return id;
}
public PropertyMap getProperties() {
public Map<String, String> getProperties() {
return properties;
}
@@ -52,5 +60,4 @@ public final class User implements Validation {
if (StringUtils.isBlank(id))
throw new JsonParseException("User id cannot be empty.");
}
}

View File

@@ -17,32 +17,62 @@
*/
package org.jackhuang.hmcl.auth.yggdrasil;
import org.jackhuang.hmcl.auth.*;
import org.jackhuang.hmcl.util.StringUtils;
import static java.util.Objects.requireNonNull;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.AuthInfo;
import org.jackhuang.hmcl.auth.AuthenticationException;
import org.jackhuang.hmcl.auth.CharacterDeletedException;
import org.jackhuang.hmcl.auth.CharacterSelector;
import org.jackhuang.hmcl.auth.CredentialExpiredException;
import org.jackhuang.hmcl.auth.NoCharacterException;
import org.jackhuang.hmcl.auth.ServerResponseMalformedException;
import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter;
import java.util.*;
/**
*
* @author huangyuhui
*/
public class YggdrasilAccount extends Account {
private final String username;
private final YggdrasilService service;
private boolean isOnline = false;
private final UUID characterUUID;
private final String username;
private boolean authenticated = false;
private YggdrasilSession session;
private UUID characterUUID;
protected YggdrasilAccount(YggdrasilService service, String username, UUID characterUUID, YggdrasilSession session) {
this.service = service;
this.username = username;
this.session = session;
this.characterUUID = characterUUID;
protected YggdrasilAccount(YggdrasilService service, String username, YggdrasilSession session) {
this.service = requireNonNull(service);
this.username = requireNonNull(username);
this.characterUUID = requireNonNull(session.getSelectedProfile().getId());
this.session = requireNonNull(session);
}
if (session == null || session.getSelectedProfile() == null || StringUtils.isBlank(session.getAccessToken()))
this.session = null;
protected YggdrasilAccount(YggdrasilService service, String username, String password, CharacterSelector selector) throws AuthenticationException {
this.service = requireNonNull(service);
this.username = requireNonNull(username);
YggdrasilSession acquiredSession = service.authenticate(username, password, randomClientToken());
if (acquiredSession.getSelectedProfile() == null) {
if (acquiredSession.getAvailableProfiles() == null || acquiredSession.getAvailableProfiles().isEmpty()) {
throw new NoCharacterException();
}
GameProfile characterToSelect = selector.select(service, acquiredSession.getAvailableProfiles());
session = service.refresh(
acquiredSession.getAccessToken(),
acquiredSession.getClientToken(),
characterToSelect);
// response validity has been checked in refresh()
} else {
session = acquiredSession;
}
characterUUID = session.getSelectedProfile().getId();
authenticated = true;
}
@Override
@@ -55,22 +85,20 @@ public class YggdrasilAccount extends Account {
return session.getSelectedProfile().getName();
}
public boolean isLoggedIn() {
return session != null && StringUtils.isNotBlank(session.getAccessToken());
}
public boolean canPlayOnline() {
return isLoggedIn() && session.getSelectedProfile() != null && isOnline;
@Override
public UUID getUUID() {
return session.getSelectedProfile().getId();
}
@Override
public synchronized AuthInfo logIn() throws AuthenticationException {
if (!canPlayOnline()) {
if (!authenticated) {
if (service.validate(session.getAccessToken(), session.getClientToken())) {
isOnline = true;
authenticated = true;
} else {
YggdrasilSession acquiredSession;
try {
updateSession(service.refresh(session.getAccessToken(), session.getClientToken(), null), new SpecificCharacterSelector(characterUUID));
acquiredSession = service.refresh(session.getAccessToken(), session.getClientToken(), null);
} catch (RemoteAuthenticationException e) {
if ("ForbiddenOperationException".equals(e.getRemoteName())) {
throw new CredentialExpiredException(e);
@@ -78,95 +106,85 @@ public class YggdrasilAccount extends Account {
throw e;
}
}
if (acquiredSession.getSelectedProfile() == null ||
!acquiredSession.getSelectedProfile().getId().equals(characterUUID)) {
throw new ServerResponseMalformedException("Selected profile changed");
}
session = acquiredSession;
authenticated = true;
invalidate();
}
}
return session.toAuthInfo();
}
@Override
public synchronized AuthInfo logInWithPassword(String password) throws AuthenticationException {
return logInWithPassword(password, new SpecificCharacterSelector(characterUUID));
}
YggdrasilSession acquiredSession = service.authenticate(username, password, randomClientToken());
protected AuthInfo logInWithPassword(String password, CharacterSelector selector) throws AuthenticationException {
updateSession(service.authenticate(username, password, UUIDTypeAdapter.fromUUID(UUID.randomUUID())), selector);
return session.toAuthInfo();
}
/**
* Updates the current session. This method shall be invoked after authenticate/refresh operation.
* {@link #session} field shall be set only using this method. This method ensures {@link #session}
* has a profile selected.
*
* @param acquiredSession the session acquired by making an authenticate/refresh request
*/
private void updateSession(YggdrasilSession acquiredSession, CharacterSelector selector) throws AuthenticationException {
if (acquiredSession.getSelectedProfile() == null) {
if (acquiredSession.getAvailableProfiles() == null || acquiredSession.getAvailableProfiles().length == 0)
throw new NoCharacterException(this);
if (acquiredSession.getAvailableProfiles() == null || acquiredSession.getAvailableProfiles().isEmpty()) {
throw new CharacterDeletedException();
}
this.session = service.refresh(
GameProfile characterToSelect = acquiredSession.getAvailableProfiles().stream()
.filter(charatcer -> charatcer.getId().equals(characterUUID))
.findFirst()
.orElseThrow(CharacterDeletedException::new);
session = service.refresh(
acquiredSession.getAccessToken(),
acquiredSession.getClientToken(),
selector.select(this, Arrays.asList(acquiredSession.getAvailableProfiles())));
characterToSelect);
} else {
this.session = acquiredSession;
if (!acquiredSession.getSelectedProfile().getId().equals(characterUUID)) {
throw new CharacterDeletedException();
}
session = acquiredSession;
}
this.characterUUID = this.session.getSelectedProfile().getId();
authenticated = true;
invalidate();
return session.toAuthInfo();
}
@Override
public Optional<AuthInfo> playOffline() {
if (isLoggedIn() && session.getSelectedProfile() != null && !canPlayOnline())
return Optional.of(session.toAuthInfo());
return Optional.empty();
return Optional.of(session.toAuthInfo());
}
@Override
public Map<Object, Object> toStorage() {
if (session == null)
throw new IllegalStateException("No session is specified");
HashMap<Object, Object> storage = new HashMap<>();
storage.put("username", getUsername());
Map<Object, Object> storage = new HashMap<>();
storage.put("username", username);
storage.putAll(session.toStorage());
service.getProfileRepository().getImmediately(characterUUID).ifPresent(profile -> {
storage.put("profileProperties", profile.getProperties());
});
return storage;
}
@Override
public UUID getUUID() {
if (session == null || session.getSelectedProfile() == null)
return null;
else
return session.getSelectedProfile().getId();
}
public Optional<Texture> getSkin() throws AuthenticationException {
return getSkin(session.getSelectedProfile());
}
public Optional<Texture> getSkin(GameProfile profile) throws AuthenticationException {
if (!service.getTextures(profile).isPresent()) {
profile = service.getCompleteGameProfile(profile.getId()).orElse(profile);
}
return service.getTextures(profile).map(map -> map.get(TextureType.SKIN));
public YggdrasilService getYggdrasilService() {
return service;
}
@Override
public void clearCache() {
Optional.ofNullable(session)
.map(YggdrasilSession::getSelectedProfile)
.map(GameProfile::getProperties)
.ifPresent(it -> it.remove("textures"));
authenticated = false;
service.getProfileRepository().invalidate(characterUUID);
}
private static String randomClientToken() {
return UUIDTypeAdapter.fromUUID(UUID.randomUUID());
}
@Override
public String toString() {
return "YggdrasilAccount[username=" + getUsername() + "]";
return "YggdrasilAccount[uuid=" + characterUUID + ", username=" + username + "]";
}
@Override

View File

@@ -20,11 +20,9 @@ package org.jackhuang.hmcl.auth.yggdrasil;
import org.jackhuang.hmcl.auth.AccountFactory;
import org.jackhuang.hmcl.auth.AuthenticationException;
import org.jackhuang.hmcl.auth.CharacterSelector;
import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import static org.jackhuang.hmcl.util.Lang.tryCast;
@@ -34,10 +32,12 @@ import static org.jackhuang.hmcl.util.Lang.tryCast;
*/
public class YggdrasilAccountFactory extends AccountFactory<YggdrasilAccount> {
private final YggdrasilProvider provider;
public static final YggdrasilAccountFactory MOJANG = new YggdrasilAccountFactory(YggdrasilService.MOJANG);
public YggdrasilAccountFactory(YggdrasilProvider provider) {
this.provider = provider;
private YggdrasilService service;
public YggdrasilAccountFactory(YggdrasilService service) {
this.service = service;
}
@Override
@@ -46,9 +46,7 @@ public class YggdrasilAccountFactory extends AccountFactory<YggdrasilAccount> {
Objects.requireNonNull(username);
Objects.requireNonNull(password);
YggdrasilAccount account = new YggdrasilAccount(new YggdrasilService(provider), username, null, null);
account.logInWithPassword(password, selector);
return account;
return new YggdrasilAccount(service, username, password, selector);
}
@Override
@@ -60,10 +58,14 @@ public class YggdrasilAccountFactory extends AccountFactory<YggdrasilAccount> {
String username = tryCast(storage.get("username"), String.class)
.orElseThrow(() -> new IllegalArgumentException("storage does not have username"));
return new YggdrasilAccount(new YggdrasilService(provider), username, session.getSelectedProfile().getId(), session);
}
tryCast(storage.get("profileProperties"), Map.class).ifPresent(
it -> {
@SuppressWarnings("unchecked")
Map<String, String> properties = it;
GameProfile selected = session.getSelectedProfile();
service.getProfileRepository().put(selected.getId(), new CompleteGameProfile(selected, properties));
});
public static String randomToken() {
return UUIDTypeAdapter.fromUUID(UUID.randomUUID());
return new YggdrasilAccount(service, username, session);
}
}

View File

@@ -27,21 +27,44 @@ import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter;
import org.jackhuang.hmcl.util.gson.ValidationTypeAdapterFactory;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.javafx.ObservableOptionalCache;
import java.io.IOException;
import java.net.URL;
import java.util.*;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Collections.unmodifiableList;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Lang.threadPool;
import static org.jackhuang.hmcl.util.Logging.LOG;
import static org.jackhuang.hmcl.util.Pair.pair;
public class YggdrasilService {
private static final ThreadPoolExecutor POOL = threadPool("ProfileProperties", true, 2, 10, TimeUnit.SECONDS);
public static final YggdrasilService MOJANG = new YggdrasilService(new MojangYggdrasilProvider());
private final YggdrasilProvider provider;
private final ObservableOptionalCache<UUID, CompleteGameProfile, AuthenticationException> profileRepository;
public YggdrasilService(YggdrasilProvider provider) {
this.provider = provider;
this.profileRepository = new ObservableOptionalCache<>(
uuid -> {
LOG.info("Fetching properties of " + uuid + " from " + provider);
return getCompleteGameProfile(uuid);
},
(uuid, e) -> LOG.log(Level.WARNING, "Failed to fetch properties of " + uuid + " from " + provider, e),
POOL);
}
public ObservableOptionalCache<UUID, CompleteGameProfile, AuthenticationException> getProfileRepository() {
return profileRepository;
}
public YggdrasilSession authenticate(String username, String password, String clientToken) throws AuthenticationException {
@@ -62,7 +85,7 @@ public class YggdrasilService {
return handleAuthenticationResponse(request(provider.getAuthenticationURL(), request), clientToken);
}
private Map<String, Object> createRequestWithCredentials(String accessToken, String clientToken) {
private static Map<String, Object> createRequestWithCredentials(String accessToken, String clientToken) {
Map<String, Object> request = new HashMap<>();
request.put("accessToken", accessToken);
request.put("clientToken", clientToken);
@@ -82,7 +105,16 @@ public class YggdrasilService {
pair("name", characterToSelect.getName())));
}
return handleAuthenticationResponse(request(provider.getRefreshmentURL(), request), clientToken);
YggdrasilSession response = handleAuthenticationResponse(request(provider.getRefreshmentURL(), request), clientToken);
if (characterToSelect != null) {
if (response.getSelectedProfile() == null ||
!response.getSelectedProfile().getId().equals(characterToSelect.getId())) {
throw new ServerResponseMalformedException("Failed to select character");
}
}
return response;
}
public boolean validate(String accessToken) throws AuthenticationException {
@@ -121,20 +153,19 @@ public class YggdrasilService {
* @param uuid the uuid that the character corresponding to.
* @return the complete game profile(filled with more properties)
*/
public Optional<GameProfile> getCompleteGameProfile(UUID uuid) throws AuthenticationException {
public Optional<CompleteGameProfile> getCompleteGameProfile(UUID uuid) throws AuthenticationException {
Objects.requireNonNull(uuid);
return Optional.ofNullable(fromJson(request(provider.getProfilePropertiesURL(uuid), null), GameProfile.class));
return Optional.ofNullable(fromJson(request(provider.getProfilePropertiesURL(uuid), null), CompleteGameProfile.class));
}
public Optional<Map<TextureType, Texture>> getTextures(GameProfile profile) throws AuthenticationException {
public static Optional<Map<TextureType, Texture>> getTextures(CompleteGameProfile profile) throws ServerResponseMalformedException {
Objects.requireNonNull(profile);
Optional<String> encodedTextures = Optional.ofNullable(profile.getProperties())
.flatMap(properties -> Optional.ofNullable(properties.get("textures")));
String encodedTextures = profile.getProperties().get("textures");
if (encodedTextures.isPresent()) {
TextureResponse texturePayload = fromJson(new String(Base64.getDecoder().decode(encodedTextures.get()), UTF_8), TextureResponse.class);
if (encodedTextures != null) {
TextureResponse texturePayload = fromJson(new String(Base64.getDecoder().decode(encodedTextures), UTF_8), TextureResponse.class);
return Optional.ofNullable(texturePayload.textures);
} else {
return Optional.empty();
@@ -148,7 +179,12 @@ public class YggdrasilService {
if (!clientToken.equals(response.clientToken))
throw new AuthenticationException("Client token changed from " + clientToken + " to " + response.clientToken);
return new YggdrasilSession(response.clientToken, response.accessToken, response.selectedProfile, response.availableProfiles, response.user);
return new YggdrasilSession(
response.clientToken,
response.accessToken,
response.selectedProfile,
response.availableProfiles == null ? null : unmodifiableList(response.availableProfiles),
response.user);
}
private static void requireEmpty(String response) throws AuthenticationException {
@@ -168,7 +204,7 @@ public class YggdrasilService {
}
}
private String request(URL url, Object payload) throws AuthenticationException {
private static String request(URL url, Object payload) throws AuthenticationException {
try {
if (payload == null)
return NetworkUtils.doGet(url);
@@ -187,26 +223,25 @@ public class YggdrasilService {
}
}
private class TextureResponse {
private static class TextureResponse {
public Map<TextureType, Texture> textures;
}
private class AuthenticationResponse extends ErrorResponse {
private static class AuthenticationResponse extends ErrorResponse {
public String accessToken;
public String clientToken;
public GameProfile selectedProfile;
public GameProfile[] availableProfiles;
public List<GameProfile> availableProfiles;
public User user;
}
private class ErrorResponse {
private static class ErrorResponse {
public String error;
public String errorMessage;
public String cause;
}
private static final Gson GSON = new GsonBuilder()
.registerTypeAdapter(PropertyMap.class, PropertyMap.Serializer.INSTANCE)
.registerTypeAdapter(UUID.class, UUIDTypeAdapter.INSTANCE)
.registerTypeAdapterFactory(ValidationTypeAdapterFactory.INSTANCE)
.create();

View File

@@ -18,10 +18,11 @@
package org.jackhuang.hmcl.auth.yggdrasil;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.jackhuang.hmcl.auth.AuthInfo;
import org.jackhuang.hmcl.util.Immutable;
import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@@ -30,15 +31,16 @@ import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Lang.tryCast;
import static org.jackhuang.hmcl.util.Pair.pair;
@Immutable
public class YggdrasilSession {
private String clientToken;
private String accessToken;
private GameProfile selectedProfile;
private GameProfile[] availableProfiles;
private User user;
private final String clientToken;
private final String accessToken;
private final GameProfile selectedProfile;
private final List<GameProfile> availableProfiles;
private final User user;
public YggdrasilSession(String clientToken, String accessToken, GameProfile selectedProfile, GameProfile[] availableProfiles, User user) {
public YggdrasilSession(String clientToken, String accessToken, GameProfile selectedProfile, List<GameProfile> availableProfiles, User user) {
this.clientToken = clientToken;
this.accessToken = accessToken;
this.selectedProfile = selectedProfile;
@@ -64,7 +66,7 @@ public class YggdrasilSession {
/**
* @return nullable (null if the YggdrasilSession is loaded from storage)
*/
public GameProfile[] getAvailableProfiles() {
public List<GameProfile> getAvailableProfiles() {
return availableProfiles;
}
@@ -78,7 +80,7 @@ public class YggdrasilSession {
String clientToken = tryCast(storage.get("clientToken"), String.class).orElseThrow(() -> new IllegalArgumentException("clientToken is missing"));
String accessToken = tryCast(storage.get("accessToken"), String.class).orElseThrow(() -> new IllegalArgumentException("accessToken is missing"));
String userId = tryCast(storage.get("userid"), String.class).orElseThrow(() -> new IllegalArgumentException("userid is missing"));
PropertyMap userProperties = tryCast(storage.get("userProperties"), Map.class).map(PropertyMap::fromMap).orElse(null);
Map<String, String> userProperties = tryCast(storage.get("userProperties"), Map.class).orElse(null);
return new YggdrasilSession(clientToken, accessToken, new GameProfile(uuid, name), null, new User(userId, userProperties));
}
@@ -107,5 +109,5 @@ public class YggdrasilSession {
Optional.ofNullable(user.getProperties()).map(GSON_PROPERTIES::toJson).orElse("{}"));
}
private static final Gson GSON_PROPERTIES = new GsonBuilder().registerTypeAdapter(PropertyMap.class, PropertyMap.LegacySerializer.INSTANCE).create();
private static final Gson GSON_PROPERTIES = new Gson();
}

View File

@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.util;
import java.util.Optional;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.function.Supplier;
@@ -31,8 +32,8 @@ import java.util.function.Supplier;
*/
public class InvocationDispatcher<ARG> implements Consumer<ARG> {
public static <ARG> InvocationDispatcher<ARG> runOn(Consumer<Runnable> executor, Consumer<ARG> action) {
return new InvocationDispatcher<>(arg -> executor.accept(() -> {
public static <ARG> InvocationDispatcher<ARG> runOn(Executor executor, Consumer<ARG> action) {
return new InvocationDispatcher<>(arg -> executor.execute(() -> {
synchronized (action) {
action.accept(arg.get());
}

View File

@@ -18,7 +18,10 @@
package org.jackhuang.hmcl.util;
import java.util.*;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import org.jackhuang.hmcl.util.function.ExceptionalRunnable;
import org.jackhuang.hmcl.util.function.ExceptionalSupplier;
@@ -172,6 +175,17 @@ public final class Lang {
return thread;
}
public static ThreadPoolExecutor threadPool(String name, boolean daemon, int threads, long timeout, TimeUnit timeunit) {
AtomicInteger counter = new AtomicInteger(1);
ThreadPoolExecutor pool = new ThreadPoolExecutor(0, threads, timeout, timeunit, new LinkedBlockingQueue<>(), r -> {
Thread t = new Thread(r, name + "-" + counter.getAndIncrement());
t.setDaemon(daemon);
return t;
});
pool.allowsCoreThreadTimeOut();
return pool;
}
public static int parseInt(Object string, int defaultValue) {
try {
return Integer.parseInt(string.toString());

View File

@@ -33,9 +33,6 @@ public final class UUIDTypeAdapter extends TypeAdapter<UUID> {
public static final UUIDTypeAdapter INSTANCE = new UUIDTypeAdapter();
private UUIDTypeAdapter() {
}
@Override
public void write(JsonWriter writer, UUID value) throws IOException {
writer.value(value == null ? null : fromUUID(value));

View File

@@ -19,9 +19,14 @@ package org.jackhuang.hmcl.util.javafx;
import static java.util.Objects.requireNonNull;
import java.util.Objects;
import java.util.concurrent.Executor;
import java.util.function.Function;
import java.util.function.Supplier;
import org.jackhuang.hmcl.util.InvocationDispatcher;
import javafx.application.Platform;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.value.ObservableValue;
@@ -53,6 +58,10 @@ public abstract class MultiStepBinding<T, U> extends ObjectBinding<U> {
return new FlatMappedBinding<>(map(mapper), nullAlternative);
}
public <V> MultiStepBinding<?, V> asyncMap(Function<U, V> mapper, V initial, Executor executor) {
return new AsyncMappedBinding<>(this, mapper, executor, initial);
}
private static class SimpleBinding<T> extends MultiStepBinding<T, T> {
public SimpleBinding(ObservableValue<T> predecessor) {
@@ -68,6 +77,11 @@ public abstract class MultiStepBinding<T, U> extends ObjectBinding<U> {
public <V> MultiStepBinding<?, V> map(Function<T, V> mapper) {
return new MappedBinding<>(predecessor, mapper);
}
@Override
public <V> MultiStepBinding<?, V> asyncMap(Function<T, V> mapper, V initial, Executor executor) {
return new AsyncMappedBinding<>(predecessor, mapper, executor, initial);
}
}
private static class MappedBinding<T, U> extends MultiStepBinding<T, U> {
@@ -119,4 +133,52 @@ public abstract class MultiStepBinding<T, U> extends ObjectBinding<U> {
}
}
}
private static class AsyncMappedBinding<T, U> extends MultiStepBinding<T, U> {
private final InvocationDispatcher<T> dispatcher;
private boolean initialized = false;
private T prev;
private U value;
public AsyncMappedBinding(ObservableValue<T> predecessor, Function<T, U> mapper, Executor executor, U initial) {
super(predecessor);
this.value = initial;
dispatcher = InvocationDispatcher.runOn(executor, arg -> {
synchronized (this) {
if (initialized && Objects.equals(arg, prev)) {
return;
}
}
U newValue = mapper.apply(arg);
synchronized (this) {
prev = arg;
value = newValue;
initialized = true;
}
Platform.runLater(this::invalidate);
});
}
// called on FX thread, this method is serial
@Override
protected U computeValue() {
T currentPrev = predecessor.getValue();
U value;
boolean updateNeeded = false;
synchronized (this) {
value = this.value;
if (!initialized || !Objects.equals(currentPrev, prev)) {
updateNeeded = true;
}
}
if (updateNeeded) {
dispatcher.accept(currentPrev);
}
return value;
}
}
}

View File

@@ -0,0 +1,162 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2019 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.util.javafx;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.Executor;
import java.util.function.BiConsumer;
import org.jackhuang.hmcl.util.function.ExceptionalFunction;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
/**
* @author yushijinhun
*/
public class ObservableCache<K, V, E extends Exception> {
private final ExceptionalFunction<K, V, E> source;
private final BiConsumer<K, Throwable> exceptionHandler;
private final V fallbackValue;
private final Executor executor;
private final ObservableHelper observable = new ObservableHelper();
private final Map<K, V> cache = new HashMap<>();
private final Map<K, CompletableFuture<V>> pendings = new HashMap<>();
private final Map<K, Boolean> invalidated = new HashMap<>();
public ObservableCache(ExceptionalFunction<K, V, E> source, BiConsumer<K, Throwable> exceptionHandler, V fallbackValue, Executor executor) {
this.source = source;
this.exceptionHandler = exceptionHandler;
this.fallbackValue = fallbackValue;
this.executor = executor;
}
public Optional<V> getImmediately(K key) {
synchronized (this) {
return Optional.ofNullable(cache.get(key));
}
}
public void put(K key, V value) {
synchronized (this) {
cache.put(key, value);
invalidated.remove(key);
}
Platform.runLater(observable::invalidate);
}
private CompletableFuture<V> query(K key, Executor executor) {
CompletableFuture<V> future;
synchronized (this) {
CompletableFuture<V> prev = pendings.get(key);
if (prev != null) {
return prev;
} else {
future = new CompletableFuture<>();
pendings.put(key, future);
}
}
executor.execute(() -> {
V result;
try {
result = source.apply(key);
} catch (Throwable ex) {
synchronized (this) {
pendings.remove(key);
}
exceptionHandler.accept(key, ex);
future.completeExceptionally(ex);
return;
}
synchronized (this) {
cache.put(key, result);
invalidated.remove(key);
pendings.remove(key, future);
}
future.complete(result);
Platform.runLater(observable::invalidate);
});
return future;
}
public V get(K key) {
V cached;
synchronized (this) {
cached = cache.get(key);
if (cached != null && !invalidated.containsKey(key)) {
return cached;
}
}
try {
return query(key, Runnable::run).join();
} catch (CompletionException | CancellationException ignored) {
}
if (cached == null) {
return fallbackValue;
} else {
return cached;
}
}
public V getDirectly(K key) throws E {
V result = source.apply(key);
put(key, result);
return result;
}
public ObjectBinding<V> binding(K key) {
return Bindings.createObjectBinding(() -> {
V result;
boolean refresh;
synchronized (this) {
result = cache.get(key);
if (result == null) {
result = fallbackValue;
refresh = true;
} else {
refresh = invalidated.containsKey(key);
}
}
if (refresh) {
query(key, executor);
}
return result;
}, observable);
}
public void invalidate(K key) {
synchronized (this) {
if (cache.containsKey(key)) {
invalidated.put(key, Boolean.TRUE);
}
}
Platform.runLater(observable::invalidate);
}
}

View File

@@ -33,6 +33,10 @@ public class ObservableHelper implements Observable, InvalidationListener {
private List<InvalidationListener> listeners = new CopyOnWriteArrayList<>();
private Observable source;
public ObservableHelper() {
this.source = this;
}
public ObservableHelper(Observable source) {
this.source = source;
}

View File

@@ -0,0 +1,62 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2019 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.util.javafx;
import java.util.Optional;
import java.util.concurrent.Executor;
import java.util.function.BiConsumer;
import org.jackhuang.hmcl.util.function.ExceptionalFunction;
import javafx.beans.binding.ObjectBinding;
/**
* @author yushijinhun
*/
public class ObservableOptionalCache<K, V, E extends Exception> {
private final ObservableCache<K, Optional<V>, E> backed;
public ObservableOptionalCache(ExceptionalFunction<K, Optional<V>, E> source, BiConsumer<K, Throwable> exceptionHandler, Executor executor) {
backed = new ObservableCache<>(source, exceptionHandler, Optional.empty(), executor);
}
public Optional<V> getImmediately(K key) {
return backed.getImmediately(key).flatMap(it -> it);
}
public void put(K key, V value) {
backed.put(key, Optional.of(value));
}
public Optional<V> get(K key) {
return backed.get(key);
}
public Optional<V> getDirectly(K key) throws E {
return backed.getDirectly(key);
}
public ObjectBinding<Optional<V>> binding(K key) {
return backed.binding(key);
}
public void invalidate(K key) {
backed.invalidate(key);
}
}