close #1376: 在用户文件夹中存储账户信息 (#1941)

* Save accounts in hmcl dir

* close #1377: Add conversion button

* fix YggdrasilAccount::getIdentifier()

* Check whether the account exists before moving

* Add server url to selected account

* Add global prefix
This commit is contained in:
Glavo 2022-12-30 02:03:19 +08:00 committed by GitHub
parent 4c2d1063ec
commit d032b3c677
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 288 additions and 126 deletions

View File

@ -17,11 +17,12 @@
*/
package org.jackhuang.hmcl.setting;
import com.google.gson.reflect.TypeToken;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyListProperty;
import javafx.beans.property.ReadOnlyListWrapper;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.*;
@ -36,14 +37,17 @@ import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccountFactory;
import org.jackhuang.hmcl.game.OAuthServer;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.util.InvocationDispatcher;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.skin.InvalidSkinException;
import java.io.IOException;
import java.io.Reader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.*;
import java.util.logging.Level;
import static java.util.stream.Collectors.toList;
@ -130,59 +134,20 @@ public final class Accounts {
throw new IllegalArgumentException("Failed to determine account type: " + account);
}
private static final String GLOBAL_PREFIX = "$GLOBAL:";
private static final ObservableList<Map<Object, Object>> globalAccountStorages = FXCollections.observableArrayList();
private static final ObservableList<Account> accounts = observableArrayList(account -> new Observable[] { account });
private static final ReadOnlyListWrapper<Account> accountsWrapper = new ReadOnlyListWrapper<>(Accounts.class, "accounts", accounts);
private static final ObjectProperty<Account> selectedAccount = new SimpleObjectProperty<Account>(Accounts.class, "selectedAccount") {
{
accounts.addListener(onInvalidating(this::invalidated));
}
@Override
protected void invalidated() {
// this methods first checks whether the current selection is valid
// if it's valid, the underlying storage will be updated
// otherwise, the first account will be selected as an alternative(or null if accounts is empty)
Account selected = get();
if (accounts.isEmpty()) {
if (selected == null) {
// valid
} else {
// the previously selected account is gone, we can only set it to null here
set(null);
return;
}
} else {
if (accounts.contains(selected)) {
// valid
} else {
// the previously selected account is gone
set(accounts.get(0));
return;
}
}
// selection is valid, store it
if (!initialized)
return;
updateAccountStorages();
}
};
private static final ObjectProperty<Account> selectedAccount = new SimpleObjectProperty<>(Accounts.class, "selectedAccount");
/**
* True if {@link #init()} hasn't been called.
*/
private static boolean initialized = false;
static {
accounts.addListener(onInvalidating(Accounts::updateAccountStorages));
}
private static Map<Object, Object> getAccountStorage(Account account) {
Map<Object, Object> storage = account.toStorage();
storage.put("type", getLoginType(getAccountFactory(account)));
if (account == selectedAccount.get()) {
storage.put("selected", true);
}
return storage;
}
@ -192,7 +157,67 @@ public final class Accounts {
if (!initialized)
return;
// update storage
config().getAccountStorages().setAll(accounts.stream().map(Accounts::getAccountStorage).collect(toList()));
ArrayList<Map<Object, Object>> global = new ArrayList<>();
ArrayList<Map<Object, Object>> portable = new ArrayList<>();
for (Account account : accounts) {
Map<Object, Object> storage = getAccountStorage(account);
if (account.isPortable())
portable.add(storage);
else
global.add(storage);
}
if (!global.equals(globalAccountStorages))
globalAccountStorages.setAll(global);
if (!portable.equals(config().getAccountStorages()))
config().getAccountStorages().setAll(portable);
}
@SuppressWarnings("unchecked")
private static void loadGlobalAccountStorages() {
Path globalAccountsFile = Metadata.HMCL_DIRECTORY.resolve("accounts.json");
if (Files.exists(globalAccountsFile)) {
try (Reader reader = Files.newBufferedReader(globalAccountsFile)) {
globalAccountStorages.setAll((List<Map<Object, Object>>)
Config.CONFIG_GSON.fromJson(reader, new TypeToken<List<Map<Object, Object>>>() {
}.getType()));
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to load global accounts", e);
}
}
InvocationDispatcher<String> dispatcher = InvocationDispatcher.runOn(Lang::thread, json -> {
LOG.info("Saving global accounts");
synchronized (globalAccountsFile) {
try {
synchronized (globalAccountsFile) {
FileUtils.saveSafely(globalAccountsFile, json);
}
} catch (IOException e) {
LOG.log(Level.SEVERE, "Failed to save global accounts", e);
}
}
});
globalAccountStorages.addListener(onInvalidating(() ->
dispatcher.accept(Config.CONFIG_GSON.toJson(globalAccountStorages))));
}
private static Account parseAccount(Map<Object, Object> storage) {
AccountFactory<?> factory = type2factory.get(storage.get("type"));
if (factory == null) {
LOG.warning("Unrecognized account type: " + storage);
return null;
}
try {
return factory.fromStorage(storage);
} catch (Exception e) {
LOG.log(Level.WARNING, "Failed to load account: " + storage, e);
return null;
}
}
/**
@ -202,38 +227,97 @@ public final class Accounts {
if (initialized)
throw new IllegalStateException("Already initialized");
// load accounts
config().getAccountStorages().forEach(storage -> {
AccountFactory<?> factory = type2factory.get(storage.get("type"));
if (factory == null) {
LOG.warning("Unrecognized account type: " + storage);
return;
}
Account account;
try {
account = factory.fromStorage(storage);
} catch (Exception e) {
LOG.log(Level.WARNING, "Failed to load account: " + storage, e);
return;
}
accounts.add(account);
loadGlobalAccountStorages();
if (Boolean.TRUE.equals(storage.get("selected"))) {
selectedAccount.set(account);
// load accounts
Account selected = null;
for (Map<Object, Object> storage : config().getAccountStorages()) {
Account account = parseAccount(storage);
if (account != null) {
account.setPortable(true);
accounts.add(account);
if (Boolean.TRUE.equals(storage.get("selected"))) {
selected = account;
}
}
});
}
for (Map<Object, Object> storage : globalAccountStorages) {
Account account = parseAccount(storage);
if (account != null) {
accounts.add(account);
}
}
String selectedAccountIdentifier = config().getSelectedAccount();
if (selected == null && selectedAccountIdentifier != null) {
boolean portable = true;
if (selectedAccountIdentifier.startsWith(GLOBAL_PREFIX)) {
portable = false;
selectedAccountIdentifier = selectedAccountIdentifier.substring(GLOBAL_PREFIX.length());
}
for (Account account : accounts) {
if (selectedAccountIdentifier.equals(account.getIdentifier())) {
if (portable == account.isPortable()) {
selected = account;
break;
} else if (selected == null) {
selected = account;
}
}
}
}
if (selected == null && !accounts.isEmpty()) {
selected = accounts.get(0);
}
selectedAccount.set(selected);
InvalidationListener listener = o -> {
// this method first checks whether the current selection is valid
// if it's valid, the underlying storage will be updated
// otherwise, the first account will be selected as an alternative(or null if accounts is empty)
Account account = selectedAccount.get();
if (accounts.isEmpty()) {
if (account == null) {
// valid
} else {
// the previously selected account is gone, we can only set it to null here
selectedAccount.set(null);
}
} else {
if (accounts.contains(account)) {
// valid
} else {
// the previously selected account is gone
selectedAccount.set(accounts.get(0));
}
}
};
selectedAccount.addListener(listener);
selectedAccount.addListener(onInvalidating(() -> {
Account account = selectedAccount.get();
if (account != null)
config().setSelectedAccount(account.isPortable() ? account.getIdentifier() : GLOBAL_PREFIX + account.getIdentifier());
else
config().setSelectedAccount(null);
}));
accounts.addListener(listener);
accounts.addListener(onInvalidating(Accounts::updateAccountStorages));
initialized = true;
config().getAuthlibInjectorServers().addListener(onInvalidating(Accounts::removeDanglingAuthlibInjectorAccounts));
Account selected = selectedAccount.get();
if (selected != null) {
Account finalSelected = selected;
Schedulers.io().execute(() -> {
try {
selected.logIn();
finalSelected.logIn();
} catch (AuthenticationException e) {
LOG.log(Level.WARNING, "Failed to log " + selected + " in", e);
LOG.log(Level.WARNING, "Failed to log " + finalSelected + " in", e);
}
});
}
@ -267,10 +351,6 @@ public final class Accounts {
return accounts;
}
public static ReadOnlyListProperty<Account> accountsProperty() {
return accountsWrapper.getReadOnlyProperty();
}
public static Account getSelectedAccount() {
return selectedAccount.get();
}

View File

@ -51,7 +51,7 @@ public final class Config implements Cloneable, Observable {
public static final int CURRENT_UI_VERSION = 0;
private static final Gson CONFIG_GSON = new GsonBuilder()
public static final Gson CONFIG_GSON = new GsonBuilder()
.registerTypeAdapter(File.class, FileTypeAdapter.INSTANCE)
.registerTypeAdapter(ObservableList.class, new ObservableListCreator())
.registerTypeAdapter(ObservableSet.class, new ObservableSetCreator())
@ -142,6 +142,9 @@ public final class Config implements Cloneable, Observable {
@SerializedName("configurations")
private SimpleMapProperty<String, Profile> configurations = new SimpleMapProperty<>(FXCollections.observableMap(new TreeMap<>()));
@SerializedName("selectedAccount")
private StringProperty selectedAccount = new SimpleStringProperty();
@SerializedName("accounts")
private ObservableList<Map<Object, Object>> accountStorages = FXCollections.observableArrayList();
@ -479,6 +482,18 @@ public final class Config implements Cloneable, Observable {
return configurations;
}
public String getSelectedAccount() {
return selectedAccount.get();
}
public void setSelectedAccount(String selectedAccount) {
this.selectedAccount.set(selectedAccount);
}
public StringProperty selectedAccountProperty() {
return selectedAccount;
}
public ObservableList<Map<Object, Object>> getAccountStorages() {
return accountStorages;
}

View File

@ -108,22 +108,5 @@ final class ConfigUpgrader {
}
});
}
tryCast(rawJson.get("selectedAccount"), String.class)
.ifPresent(selected -> {
deserialized.getAccountStorages().stream()
.filter(storage -> {
Object type = storage.get("type");
if ("offline".equals(type)) {
return selected.equals(storage.get("username") + ":" + storage.get("username"));
} else if ("yggdrasil".equals(type) || "authlibInjector".equals(type)) {
return selected.equals(storage.get("username") + ":" + storage.get("displayName"));
} else {
return false;
}
})
.findFirst()
.ifPresent(storage -> storage.put("selected", true));
});
}
}

View File

@ -21,42 +21,23 @@ import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.*;
import javafx.collections.ObservableList;
import javafx.collections.ObservableMap;
import javafx.collections.ObservableSet;
import org.hildan.fxgson.creators.ObservableListCreator;
import org.hildan.fxgson.creators.ObservableMapCreator;
import org.hildan.fxgson.creators.ObservableSetCreator;
import org.hildan.fxgson.factories.JavaFxPropertyTypeAdapterFactory;
import org.jackhuang.hmcl.util.gson.EnumOrdinalDeserializer;
import org.jackhuang.hmcl.util.gson.FileTypeAdapter;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleIntegerProperty;
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
import org.jackhuang.hmcl.util.javafx.PropertyUtils;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.lang.reflect.Type;
import java.net.Proxy;
import java.util.*;
@JsonAdapter(GlobalConfig.Serializer.class)
public class GlobalConfig implements Cloneable, Observable {
private static final Gson CONFIG_GSON = new GsonBuilder()
.registerTypeAdapter(File.class, FileTypeAdapter.INSTANCE)
.registerTypeAdapter(ObservableList.class, new ObservableListCreator())
.registerTypeAdapter(ObservableSet.class, new ObservableSetCreator())
.registerTypeAdapter(ObservableMap.class, new ObservableMapCreator())
.registerTypeAdapterFactory(new JavaFxPropertyTypeAdapterFactory(true, true))
.registerTypeAdapter(EnumBackgroundImage.class, new EnumOrdinalDeserializer<>(EnumBackgroundImage.class)) // backward compatibility for backgroundType
.registerTypeAdapter(Proxy.Type.class, new EnumOrdinalDeserializer<>(Proxy.Type.class)) // backward compatibility for hasProxy
.setPrettyPrinting()
.create();
@Nullable
public static GlobalConfig fromJson(String json) throws JsonParseException {
GlobalConfig loaded = CONFIG_GSON.fromJson(json, GlobalConfig.class);
GlobalConfig loaded = Config.CONFIG_GSON.fromJson(json, GlobalConfig.class);
if (loaded == null) {
return null;
}
@ -93,7 +74,7 @@ public class GlobalConfig implements Cloneable, Observable {
}
public String toJson() {
return CONFIG_GSON.toJson(this);
return Config.CONFIG_GSON.toJson(this);
}
@Override

View File

@ -89,7 +89,7 @@ public final class Controllers {
private static Lazy<AccountListPage> accountListPage = new Lazy<>(() -> {
AccountListPage accountListPage = new AccountListPage();
accountListPage.selectedAccountProperty().bindBidirectional(Accounts.selectedAccountProperty());
accountListPage.accountsProperty().bindContent(Accounts.accountsProperty());
accountListPage.accountsProperty().bindContent(Accounts.getAccounts());
accountListPage.authServersProperty().bindContentBidirectional(config().getAuthlibInjectorServers());
return accountListPage;
});

View File

@ -73,13 +73,14 @@ public class AccountListItem extends RadioButton {
setUserData(account);
String loginTypeName = Accounts.getLocalizedLoginTypeName(Accounts.getAccountFactory(account));
String portableSuffix = account.isPortable() ? ", " + i18n("account.portable") : "";
if (account instanceof AuthlibInjectorAccount) {
AuthlibInjectorServer server = ((AuthlibInjectorAccount) account).getServer();
subtitle.bind(Bindings.concat(
loginTypeName, ", ", i18n("account.injector.server"), ": ",
Bindings.createStringBinding(server::getName, server)));
Bindings.createStringBinding(server::getName, server), portableSuffix));
} else {
subtitle.set(loginTypeName);
subtitle.set(loginTypeName + portableSuffix);
}
StringBinding characterName = Bindings.createStringBinding(account::getCharacter, account);

View File

@ -28,6 +28,7 @@ import javafx.scene.control.Tooltip;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.game.TexturesLoader;
@ -90,6 +91,41 @@ public class AccountListItemSkin extends SkinBase<AccountListItem> {
HBox right = new HBox();
right.setAlignment(Pos.CENTER_RIGHT);
JFXButton btnMove = new JFXButton();
SpinnerPane spinnerMove = new SpinnerPane();
spinnerMove.getStyleClass().add("small-spinner-pane");
btnMove.setOnMouseClicked(e -> {
Account account = skinnable.getAccount();
Accounts.getAccounts().remove(account);
if (account.isPortable()) {
account.setPortable(false);
if (!Accounts.getAccounts().contains(account))
Accounts.getAccounts().add(account);
} else {
account.setPortable(true);
if (!Accounts.getAccounts().contains(account)) {
int idx = 0;
for (int i = Accounts.getAccounts().size() - 1; i >= 0; i--) {
if (Accounts.getAccounts().get(i).isPortable()) {
idx = i + 1;
break;
}
}
Accounts.getAccounts().add(idx, account);
}
}
});
btnMove.getStyleClass().add("toggle-icon4");
if (skinnable.getAccount().isPortable()) {
btnMove.setGraphic(SVG.earth(Theme.blackFillBinding(), -1, -1));
runInFX(() -> FXUtils.installFastTooltip(btnMove, i18n("account.move_to_global")));
} else {
btnMove.setGraphic(SVG.export(Theme.blackFillBinding(), -1, -1));
runInFX(() -> FXUtils.installFastTooltip(btnMove, i18n("account.move_to_portable")));
}
spinnerMove.setContent(btnMove);
right.getChildren().add(spinnerMove);
JFXButton btnRefresh = new JFXButton();
SpinnerPane spinnerRefresh = new SpinnerPane();
spinnerRefresh.getStyleClass().setAll("small-spinner-pane");

View File

@ -138,8 +138,11 @@ The link down below will guide you to migrate your Mojang account to a Microsoft
account.methods.yggdrasil.purchase=Buy Minecraft
account.missing=No Accounts
account.missing.add=Click here to add one.
account.move_to_global=Convert to global account
account.move_to_portable=Convert to portable account
account.not_logged_in=Not Logged in
account.password=Password
account.portable=Portable Account
account.skin=Skin
account.skin.file=Skin File
account.skin.model=Model

View File

@ -125,8 +125,11 @@ account.methods.yggdrasil.migration.hint=自 2022 年 3 月 10 日起Mojang
account.methods.yggdrasil.purchase=購買 Minecraft
account.missing=沒有遊戲帳戶
account.missing.add=按一下此處加入帳戶
account.move_to_global=轉換為全域帳戶
account.move_to_portable=轉換為便攜帳戶
account.not_logged_in=未登入
account.password=密碼
account.portable=便攜帳戶
account.skin=皮膚
account.skin.file=皮膚圖片檔案
account.skin.model=模型

View File

@ -125,8 +125,11 @@ account.methods.yggdrasil.migration.hint=自 2022 年 3 月 10 日起Mojang
account.methods.yggdrasil.purchase=购买 Minecraft
account.missing=没有游戏帐户
account.missing.add=点击此处添加帐户
account.move_to_global=转换为全局账户
account.move_to_portable=转换为便携账户
account.not_logged_in=未登录
account.password=密码
account.portable=便携账户
account.skin=皮肤
account.skin.file=皮肤图片文件
account.skin.model=模型

View File

@ -23,12 +23,15 @@ import javafx.beans.Observable;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import org.jackhuang.hmcl.auth.yggdrasil.Texture;
import org.jackhuang.hmcl.auth.yggdrasil.TextureType;
import org.jackhuang.hmcl.util.ToStringBuilder;
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
@ -71,7 +74,23 @@ public abstract class Account implements Observable {
public void clearCache() {
}
private ObservableHelper helper = new ObservableHelper(this);
private final BooleanProperty portable = new SimpleBooleanProperty(false);
public BooleanProperty portableProperty() {
return portable;
}
public boolean isPortable() {
return portable.get();
}
public void setPortable(boolean value) {
this.portable.set(value);
}
public abstract String getIdentifier();
private final ObservableHelper helper = new ObservableHelper(this);
@Override
public void addListener(InvalidationListener listener) {
@ -95,12 +114,29 @@ public abstract class Account implements Observable {
return Bindings.createObjectBinding(Optional::empty);
}
@Override
public int hashCode() {
return Objects.hash(portable);
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (!(obj instanceof Account))
return false;
Account another = (Account) obj;
return isPortable() == another.isPortable();
}
@Override
public String toString() {
return new ToStringBuilder(this)
.append("username", getUsername())
.append("character", getCharacter())
.append("uuid", getUUID())
.append("portable", isPortable())
.toString();
}
}

View File

@ -153,6 +153,11 @@ public class AuthlibInjectorAccount extends YggdrasilAccount {
return server;
}
@Override
public String getIdentifier() {
return server.getUrl() + ":" + super.getIdentifier();
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), server.hashCode());
@ -163,7 +168,8 @@ public class AuthlibInjectorAccount extends YggdrasilAccount {
if (obj == null || obj.getClass() != AuthlibInjectorAccount.class)
return false;
AuthlibInjectorAccount another = (AuthlibInjectorAccount) obj;
return characterUUID.equals(another.characterUUID) && server.equals(another.server);
return isPortable() == another.isPortable()
&& characterUUID.equals(another.characterUUID) && server.equals(another.server);
}
@Override

View File

@ -77,6 +77,11 @@ public class MicrosoftAccount extends OAuthAccount {
return session.getProfile().getId();
}
@Override
public String getIdentifier() {
return "microsoft:" + getUUID();
}
@Override
public AuthInfo logIn() throws AuthenticationException {
if (!authenticated) {
@ -163,6 +168,6 @@ public class MicrosoftAccount extends OAuthAccount {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MicrosoftAccount that = (MicrosoftAccount) o;
return characterUUID.equals(that.characterUUID);
return this.isPortable() == that.isPortable() && characterUUID.equals(that.characterUUID);
}
}

View File

@ -86,6 +86,11 @@ public class OfflineAccount extends Account {
return username;
}
@Override
public String getIdentifier() {
return username + ":" + username;
}
public Skin getSkin() {
return skin;
}
@ -222,6 +227,6 @@ public class OfflineAccount extends Account {
if (!(obj instanceof OfflineAccount))
return false;
OfflineAccount another = (OfflineAccount) obj;
return username.equals(another.username);
return isPortable() == another.isPortable() && username.equals(another.username);
}
}

View File

@ -98,6 +98,11 @@ public class YggdrasilAccount extends ClassicAccount {
return session.getSelectedProfile().getId();
}
@Override
public String getIdentifier() {
return getUsername() + ":" + getUUID();
}
@Override
public synchronized AuthInfo logIn() throws AuthenticationException {
if (!authenticated) {
@ -223,6 +228,6 @@ public class YggdrasilAccount extends ClassicAccount {
if (obj == null || obj.getClass() != YggdrasilAccount.class)
return false;
YggdrasilAccount another = (YggdrasilAccount) obj;
return characterUUID.equals(another.characterUUID);
return isPortable() == another.isPortable() && characterUUID.equals(another.characterUUID);
}
}