diff --git a/build.gradle b/build.gradle index c2d799d..00ce4bf 100644 --- a/build.gradle +++ b/build.gradle @@ -27,10 +27,10 @@ apply plugin: 'maven' group = 'com.skcraft' - version = '4.4-SNAPSHOT' + version = '4.5-SNAPSHOT' - sourceCompatibility = 1.6 - targetCompatibility = 1.6 + sourceCompatibility = 1.8 + targetCompatibility = 1.8 repositories { mavenCentral() @@ -42,4 +42,9 @@ options.addStringOption('Xdoclint:none', '-quiet') } } + + tasks.withType(JavaExec) { + workingDir = new File(rootDir, "run/") + workingDir.mkdirs() + } } diff --git a/creator-tools/src/main/java/com/skcraft/launcher/creator/controller/PackManagerController.java b/creator-tools/src/main/java/com/skcraft/launcher/creator/controller/PackManagerController.java index be8e9dc..f678c01 100644 --- a/creator-tools/src/main/java/com/skcraft/launcher/creator/controller/PackManagerController.java +++ b/creator-tools/src/main/java/com/skcraft/launcher/creator/controller/PackManagerController.java @@ -21,18 +21,18 @@ import com.skcraft.launcher.builder.BuilderConfig; import com.skcraft.launcher.builder.FnPatternList; import com.skcraft.launcher.creator.Creator; -import com.skcraft.launcher.creator.model.creator.*; import com.skcraft.launcher.creator.controller.task.*; import com.skcraft.launcher.creator.dialog.*; import com.skcraft.launcher.creator.dialog.BuildDialog.BuildOptions; import com.skcraft.launcher.creator.dialog.DeployServerDialog.DeployOptions; +import com.skcraft.launcher.creator.model.creator.*; import com.skcraft.launcher.creator.model.swing.PackTableModel; import com.skcraft.launcher.creator.server.TestServer; import com.skcraft.launcher.creator.server.TestServerBuilder; import com.skcraft.launcher.creator.swing.PackDirectoryFilter; +import com.skcraft.launcher.dialog.AccountSelectDialog; import com.skcraft.launcher.dialog.ConfigurationDialog; import com.skcraft.launcher.dialog.ConsoleFrame; -import com.skcraft.launcher.dialog.LoginDialog; import com.skcraft.launcher.dialog.ProgressDialog; import com.skcraft.launcher.model.modpack.LaunchModifier; import com.skcraft.launcher.persistence.Persistence; @@ -225,7 +225,7 @@ if (config.isOfflineEnabled()) { return true; } else { - Session session = LoginDialog.showLoginRequest(frame, launcher); + Session session = AccountSelectDialog.showAccountRequest(frame, launcher); if (session != null) { config.setOfflineEnabled(true); Persistence.commitAndForget(config); @@ -734,7 +734,7 @@ Session session; if (online) { - session = LoginDialog.showLoginRequest(frame, launcher); + session = AccountSelectDialog.showAccountRequest(frame, launcher); if (session == null) { return; } diff --git a/launcher/src/main/java/com/skcraft/launcher/Launcher.java b/launcher/src/main/java/com/skcraft/launcher/Launcher.java index 5b4aa18..b2c9508 100644 --- a/launcher/src/main/java/com/skcraft/launcher/Launcher.java +++ b/launcher/src/main/java/com/skcraft/launcher/Launcher.java @@ -12,9 +12,7 @@ import com.google.common.base.Supplier; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; -import com.skcraft.launcher.auth.AccountList; -import com.skcraft.launcher.auth.LoginService; -import com.skcraft.launcher.auth.YggdrasilLoginService; +import com.skcraft.launcher.auth.*; import com.skcraft.launcher.launch.LaunchSupervisor; import com.skcraft.launcher.model.minecraft.Library; import com.skcraft.launcher.model.minecraft.VersionManifest; @@ -100,10 +98,6 @@ setDefaultConfig(); - if (accounts.getSize() > 0) { - accounts.setSelectedItem(accounts.getElementAt(0)); - } - executor.submit(new Runnable() { @Override public void run() { @@ -161,12 +155,29 @@ } /** - * Get a login service. + * Get the Yggdrasil login service. * - * @return a login service + * @return the Yggdrasil (legacy) login service */ - public LoginService getLoginService() { - return new YggdrasilLoginService(HttpRequest.url(getProperties().getProperty("yggdrasilAuthUrl"))); + public YggdrasilLoginService getYggdrasil() { + return new YggdrasilLoginService(HttpRequest.url(getProperties().getProperty("yggdrasilAuthUrl")), accounts.getClientId()); + } + + /** + * Get the Microsoft login service. + * + * @return the Microsoft (current) login service + */ + public MicrosoftLoginService getMicrosoftLogin() { + return new MicrosoftLoginService(getProperties().getProperty("microsoftClientId")); + } + + public LoginService getLoginService(UserType type) { + if (type == UserType.MICROSOFT) { + return getMicrosoftLogin(); + } else { + return getYggdrasil(); + } } /** diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/Account.java b/launcher/src/main/java/com/skcraft/launcher/auth/Account.java deleted file mode 100644 index e99bade..0000000 --- a/launcher/src/main/java/com/skcraft/launcher/auth/Account.java +++ /dev/null @@ -1,91 +0,0 @@ -/* - * SK's Minecraft Launcher - * Copyright (C) 2010-2014 Albert Pham and contributors - * Please see LICENSE.txt for license information. - */ - -package com.skcraft.launcher.auth; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.google.common.base.Strings; -import lombok.Data; -import lombok.NonNull; - -import java.util.Date; - -/** - * A user account that can be stored and loaded. - */ -@Data -@JsonIgnoreProperties(ignoreUnknown = true) -public class Account implements Comparable { - - private String id; - private String password; - private Date lastUsed; - - /** - * Create a new account. - */ - public Account() { - } - - /** - * Create a new account with the given ID. - * - * @param id the ID - */ - public Account(String id) { - setId(id); - } - - /** - * Set the account's stored password, that may be stored to disk. - * - * @param password the password - */ - public void setPassword(String password) { - if (password != null && password.isEmpty()) { - password = null; - } - this.password = Strings.emptyToNull(password); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - Account account = (Account) o; - - if (!id.equalsIgnoreCase(account.id)) return false; - - return true; - } - - @Override - public int hashCode() { - return id.toLowerCase().hashCode(); - } - - @Override - public int compareTo(@NonNull Account o) { - Date otherDate = o.getLastUsed(); - - if (otherDate == null && lastUsed == null) { - return 0; - } else if (otherDate == null) { - return -1; - } else if (lastUsed == null) { - return 1; - } else { - return -lastUsed.compareTo(otherDate); - } - } - - @Override - public String toString() { - return getId(); - } - -} diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/AccountList.java b/launcher/src/main/java/com/skcraft/launcher/auth/AccountList.java index ddf9cb0..0595577 100644 --- a/launcher/src/main/java/com/skcraft/launcher/auth/AccountList.java +++ b/launcher/src/main/java/com/skcraft/launcher/auth/AccountList.java @@ -1,134 +1,78 @@ -/* - * SK's Minecraft Launcher - * Copyright (C) 2010-2014 Albert Pham and contributors - * Please see LICENSE.txt for license information. - */ - package com.skcraft.launcher.auth; -import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.beust.jcommander.internal.Lists; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; +import com.skcraft.launcher.dialog.component.ListListenerReducer; import com.skcraft.launcher.persistence.Scrambled; import lombok.Getter; -import lombok.NonNull; +import lombok.Setter; +import lombok.ToString; +import org.apache.commons.lang.RandomStringUtils; import javax.swing.*; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Iterator; +import javax.swing.event.ListDataEvent; +import javax.swing.event.ListDataListener; import java.util.List; /** - * A list of accounts that can be stored to disk. + * Persisted account list */ -@Scrambled("ACCOUNT_LIST") +@Scrambled("ACCOUNT_LIST_NOT_SECURITY!") +@Getter +@Setter +@ToString @JsonIgnoreProperties(ignoreUnknown = true) -@JsonAutoDetect( - getterVisibility = JsonAutoDetect.Visibility.NONE, - setterVisibility = JsonAutoDetect.Visibility.NONE, - fieldVisibility = JsonAutoDetect.Visibility.NONE) -public class AccountList extends AbstractListModel implements ComboBoxModel { +public class AccountList implements ListModel { + private List accounts = Lists.newArrayList(); + private String clientId = RandomStringUtils.randomAlphanumeric(24); - @JsonProperty - @Getter - private List accounts = new ArrayList(); - private transient Account selected; + @JsonIgnore private final ListListenerReducer listeners = new ListListenerReducer(); - /** - * Add a new account. - * - *

If there is already an existing account with the same ID, then the - * new account will not be added.

- * - * @param account the account to add - */ - public synchronized void add(@NonNull Account account) { - if (!accounts.contains(account)) { - accounts.add(account); - Collections.sort(accounts); - fireContentsChanged(this, 0, accounts.size()); - } - } + public synchronized void add(SavedSession session) { + accounts.add(session); - /** - * Remove an account. - * - * @param account the account - */ - public synchronized void remove(@NonNull Account account) { - Iterator it = accounts.iterator(); - while (it.hasNext()) { - Account other = it.next(); - if (other.equals(account)) { - it.remove(); - fireContentsChanged(this, 0, accounts.size() + 1); - break; - } - } - } + int index = accounts.size() - 1; + listeners.intervalAdded(new ListDataEvent(this, ListDataEvent.INTERVAL_ADDED, index, index)); + } - /** - * Set the list of accounts. - * - * @param accounts the list of accounts - */ - public synchronized void setAccounts(@NonNull List accounts) { - this.accounts = accounts; - Collections.sort(accounts); - } + public synchronized void remove(SavedSession session) { + int index = accounts.indexOf(session); - @Override - @JsonIgnore - public synchronized int getSize() { - return accounts.size(); - } + if (index > -1) { + accounts.remove(index); + listeners.intervalRemoved(new ListDataEvent(this, ListDataEvent.INTERVAL_REMOVED, index, index)); + } + } - @Override - public synchronized Account getElementAt(int index) { - try { - return accounts.get(index); - } catch (IndexOutOfBoundsException e) { - return null; - } - } + public synchronized void update(SavedSession newSavedSession) { + int index = accounts.indexOf(newSavedSession); - @Override - public void setSelectedItem(Object item) { - if (item == null) { - selected = null; - return; - } + if (index > -1) { + accounts.set(index, newSavedSession); + listeners.contentsChanged(new ListDataEvent(this, ListDataEvent.CONTENTS_CHANGED, index, index)); + } else { + this.add(newSavedSession); + } + } - if (item instanceof Account) { - this.selected = (Account) item; - } else { - String id = String.valueOf(item).trim(); - Account account = new Account(id); - for (Account test : accounts) { - if (test.equals(account)) { - account = test; - break; - } - } - selected = account; - } + @Override + public int getSize() { + return accounts.size(); + } - if (selected.getId() == null || selected.getId().isEmpty()) { - selected = null; - } - } + @Override + public SavedSession getElementAt(int index) { + return accounts.get(index); + } - @Override - @JsonIgnore - public Account getSelectedItem() { - return selected; - } + @Override + public void addListDataListener(ListDataListener l) { + listeners.addListDataListener(l); + } - public synchronized void forgetPasswords() { - for (Account account : accounts) { - account.setPassword(null); - } - } + @Override + public void removeListDataListener(ListDataListener l) { + listeners.removeListDataListener(l); + } } diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/AuthenticationException.java b/launcher/src/main/java/com/skcraft/launcher/auth/AuthenticationException.java index 4dc3f4f..675d32e 100644 --- a/launcher/src/main/java/com/skcraft/launcher/auth/AuthenticationException.java +++ b/launcher/src/main/java/com/skcraft/launcher/auth/AuthenticationException.java @@ -17,6 +17,10 @@ super(message, localizedMessage); } + public AuthenticationException(String message) { + super(message, message); + } + public AuthenticationException(Throwable cause, String localizedMessage) { super(cause, localizedMessage); } diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/LoginService.java b/launcher/src/main/java/com/skcraft/launcher/auth/LoginService.java index 60419ed..f6da3d2 100644 --- a/launcher/src/main/java/com/skcraft/launcher/auth/LoginService.java +++ b/launcher/src/main/java/com/skcraft/launcher/auth/LoginService.java @@ -7,7 +7,6 @@ package com.skcraft.launcher.auth; import java.io.IOException; -import java.util.List; /** * A service for creating authenticated sessions. @@ -15,17 +14,15 @@ public interface LoginService { /** - * Attempt to login with the given details. + * Attempt to restore a saved session into an active session. * - * @param agent the game to authenticate for, such as "Minecraft" - * @param id the login ID - * @param password the password - * @return a list of authenticated sessions, which corresponds to identities + * @param savedSession Session to restore + * @return An authenticated session, which corresponds to a Minecraft account * @throws IOException thrown on I/O error * @throws InterruptedException thrown if interrupted * @throws AuthenticationException thrown on an authentication error */ - List login(String agent, String id, String password) + Session restore(SavedSession savedSession) throws IOException, InterruptedException, AuthenticationException; } diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/MicrosoftLoginService.java b/launcher/src/main/java/com/skcraft/launcher/auth/MicrosoftLoginService.java new file mode 100644 index 0000000..80d331a --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/auth/MicrosoftLoginService.java @@ -0,0 +1,177 @@ +package com.skcraft.launcher.auth; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.skcraft.launcher.auth.microsoft.MicrosoftWebAuthorizer; +import com.skcraft.launcher.auth.microsoft.MinecraftServicesAuthorizer; +import com.skcraft.launcher.auth.microsoft.OauthResult; +import com.skcraft.launcher.auth.microsoft.XboxTokenAuthorizer; +import com.skcraft.launcher.auth.microsoft.model.McAuthResponse; +import com.skcraft.launcher.auth.microsoft.model.McProfileResponse; +import com.skcraft.launcher.auth.microsoft.model.TokenResponse; +import com.skcraft.launcher.auth.microsoft.model.XboxAuthorization; +import com.skcraft.launcher.util.HttpRequest; +import lombok.Data; +import lombok.RequiredArgsConstructor; + +import java.io.IOException; +import java.net.URL; +import java.util.Collections; +import java.util.Map; +import java.util.function.Consumer; + +import static com.skcraft.launcher.util.HttpRequest.url; + +@RequiredArgsConstructor +public class MicrosoftLoginService implements LoginService { + private static final URL MS_TOKEN_URL = url("https://login.live.com/oauth20_token.srf"); + + private final String clientId; + + /** + * Trigger a full login sequence with the Microsoft authenticator. + * + * @param oauthDone Callback called when OAuth is complete and automatic login is about to begin. + * @return Valid {@link Session} instance representing the logged-in player. + * @throws IOException if any I/O error occurs. + * @throws InterruptedException if the current thread is interrupted + * @throws AuthenticationException if authentication fails in any way, this is thrown with a human-useful message. + */ + public Session login(Receiver oauthDone) throws IOException, InterruptedException, AuthenticationException { + MicrosoftWebAuthorizer authorizer = new MicrosoftWebAuthorizer(clientId); + OauthResult auth = authorizer.authorize(); + + if (auth.isError()) { + OauthResult.Error error = (OauthResult.Error) auth; + throw new AuthenticationException(error.getErrorMessage()); + } + + TokenResponse response = exchangeToken(form -> { + form.add("grant_type", "authorization_code"); + form.add("redirect_uri", authorizer.getRedirectUri()); + form.add("code", ((OauthResult.Success) auth).getAuthCode()); + }); + + oauthDone.tell(); + Profile session = performLogin(response.getAccessToken(), null); + session.setRefreshToken(response.getRefreshToken()); + + return session; + } + + @Override + public Session restore(SavedSession savedSession) + throws IOException, InterruptedException, AuthenticationException { + TokenResponse response = exchangeToken(form -> { + form.add("grant_type", "refresh_token"); + form.add("refresh_token", savedSession.getRefreshToken()); + }); + + Profile session = performLogin(response.getAccessToken(), savedSession); + session.setRefreshToken(response.getRefreshToken()); + + return session; + } + + private TokenResponse exchangeToken(Consumer formConsumer) + throws IOException, InterruptedException, AuthenticationException { + HttpRequest.Form form = HttpRequest.Form.form(); + form.add("client_id", clientId); + formConsumer.accept(form); + + return HttpRequest.post(MS_TOKEN_URL) + .bodyForm(form) + .execute() + .expectResponseCodeOr(200, (req) -> { + TokenError error = req.returnContent().asJson(TokenError.class); + + return new AuthenticationException(error.errorDescription); + }) + .returnContent() + .asJson(TokenResponse.class); + } + + private Profile performLogin(String microsoftToken, SavedSession previous) + throws IOException, InterruptedException, AuthenticationException { + XboxAuthorization xboxAuthorization = XboxTokenAuthorizer.authorizeWithXbox(microsoftToken); + McAuthResponse auth = MinecraftServicesAuthorizer.authorizeWithMinecraft(xboxAuthorization); + McProfileResponse profile = MinecraftServicesAuthorizer.getUserProfile(auth); + + Profile session = new Profile(auth, profile); + if (previous != null && previous.getAvatarImage() != null) { + session.setAvatarImage(previous.getAvatarImage()); + } else { + session.setAvatarImage(VisageSkinService.fetchSkinHead(profile.getUuid())); + } + + + return session; + } + + @Data + public static class Profile implements Session { + private final McAuthResponse auth; + private final McProfileResponse profile; + private final Map userProperties = Collections.emptyMap(); + private String refreshToken; + private byte[] avatarImage; + + @Override + public String getUuid() { + return profile.getUuid(); + } + + @Override + public String getName() { + return profile.getName(); + } + + @Override + public String getAccessToken() { + return auth.getAccessToken(); + } + + @Override + public String getSessionToken() { + return String.format("token:%s:%s", getAccessToken(), getUuid()); + } + + @Override + public UserType getUserType() { + return UserType.MICROSOFT; + } + + @Override + public boolean isOnline() { + return true; + } + + @Override + public SavedSession toSavedSession() { + SavedSession savedSession = new SavedSession(); + + savedSession.setType(getUserType()); + savedSession.setUsername(getName()); + savedSession.setUuid(getUuid()); + savedSession.setAccessToken(getAccessToken()); + savedSession.setRefreshToken(getRefreshToken()); + savedSession.setAvatarImage(getAvatarImage()); + + return savedSession; + } + } + + @Data + @JsonNaming(PropertyNamingStrategy.LowerCaseWithUnderscoresStrategy.class) + @JsonIgnoreProperties(ignoreUnknown = true) + private static class TokenError { + private String error; + private String errorDescription; + } + + @FunctionalInterface + public interface Receiver { + void tell(); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/OfflineSession.java b/launcher/src/main/java/com/skcraft/launcher/auth/OfflineSession.java index 0431b2c..cb2520a 100644 --- a/launcher/src/main/java/com/skcraft/launcher/auth/OfflineSession.java +++ b/launcher/src/main/java/com/skcraft/launcher/auth/OfflineSession.java @@ -38,11 +38,6 @@ } @Override - public String getClientToken() { - return "0"; - } - - @Override public String getAccessToken() { return "0"; } @@ -63,6 +58,11 @@ } @Override + public byte[] getAvatarImage() { + return null; + } + + @Override public boolean isOnline() { return false; } diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/SavedSession.java b/launcher/src/main/java/com/skcraft/launcher/auth/SavedSession.java new file mode 100644 index 0000000..9e12f1b --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/auth/SavedSession.java @@ -0,0 +1,37 @@ +package com.skcraft.launcher.auth; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; +import org.apache.commons.lang.builder.HashCodeBuilder; + +/** + * Represents a session saved to disk. + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SavedSession { + private UserType type; + private String uuid; + private String username; + private String accessToken; + private String refreshToken; + private byte[] avatarImage; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + + if (o == null || getClass() != o.getClass()) return false; + + SavedSession that = (SavedSession) o; + + return getUuid().equals(that.getUuid()); + } + + @Override + public int hashCode() { + return new HashCodeBuilder(17, 37) + .append(uuid) + .toHashCode(); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/Session.java b/launcher/src/main/java/com/skcraft/launcher/auth/Session.java index 95b3e58..6535c14 100644 --- a/launcher/src/main/java/com/skcraft/launcher/auth/Session.java +++ b/launcher/src/main/java/com/skcraft/launcher/auth/Session.java @@ -28,13 +28,6 @@ String getName(); /** - * Get the client token. - * - * @return client token - */ - String getClientToken(); - - /** * Get the access token. * * @return the access token @@ -65,10 +58,33 @@ UserType getUserType(); /** + * Get the user's avatar + * + * @return User's avatar as a base64 string. + */ + byte[] getAvatarImage(); + + /** * Return true if the user is in an online session. * * @return true if online */ boolean isOnline(); + /** + * Convert this session to a saved session + * @return Saved session that represents this active session + */ + default SavedSession toSavedSession() { + SavedSession savedSession = new SavedSession(); + + savedSession.setType(getUserType()); + savedSession.setUsername(getName()); + savedSession.setUuid(getUuid()); + savedSession.setAccessToken(getAccessToken()); + savedSession.setAvatarImage(getAvatarImage()); + + return savedSession; + } + } diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/UserType.java b/launcher/src/main/java/com/skcraft/launcher/auth/UserType.java index 7dfeff6..1d60b29 100644 --- a/launcher/src/main/java/com/skcraft/launcher/auth/UserType.java +++ b/launcher/src/main/java/com/skcraft/launcher/auth/UserType.java @@ -18,7 +18,11 @@ /** * Mojang accounts login with an email address. */ - MOJANG; + MOJANG, + /** + * Microsoft accounts login via OAuth. + */ + MICROSOFT; /** * Return a lowercase version of the enum type. diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/VisageSkinService.java b/launcher/src/main/java/com/skcraft/launcher/auth/VisageSkinService.java new file mode 100644 index 0000000..12fe5d8 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/auth/VisageSkinService.java @@ -0,0 +1,19 @@ +package com.skcraft.launcher.auth; + +import com.skcraft.launcher.util.HttpRequest; + +import java.io.IOException; + +import static com.skcraft.launcher.util.HttpRequest.url; + +public class VisageSkinService { + public static byte[] fetchSkinHead(String uuid) throws IOException, InterruptedException { + String skinUrl = String.format("https://visage.surgeplay.com/face/32/%s.png", uuid); + + return HttpRequest.get(url(skinUrl)) + .execute() + .expectResponseCode(200) + .returnContent() + .asBytes(); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/YggdrasilLoginService.java b/launcher/src/main/java/com/skcraft/launcher/auth/YggdrasilLoginService.java index b686953..0f5ce87 100644 --- a/launcher/src/main/java/com/skcraft/launcher/auth/YggdrasilLoginService.java +++ b/launcher/src/main/java/com/skcraft/launcher/auth/YggdrasilLoginService.java @@ -9,47 +9,60 @@ import com.fasterxml.jackson.annotation.*; import com.skcraft.launcher.util.HttpRequest; import lombok.Data; -import lombok.NonNull; +import lombok.RequiredArgsConstructor; import lombok.ToString; import java.io.IOException; import java.net.URL; import java.util.Collections; -import java.util.List; import java.util.Map; /** * Creates authenticated sessions using the Mojang Yggdrasil login protocol. */ +@RequiredArgsConstructor public class YggdrasilLoginService implements LoginService { private final URL authUrl; + private final String clientId; - /** - * Create a new login service with the given authentication URL. - * - * @param authUrl the authentication URL - */ - public YggdrasilLoginService(@NonNull URL authUrl) { - this.authUrl = authUrl; + public Session login(String id, String password) + throws IOException, InterruptedException, AuthenticationException { + AuthenticatePayload payload = new AuthenticatePayload(new Agent("Minecraft"), id, password, clientId); + + return call(this.authUrl, payload, null); } @Override - public List login(String agent, String id, String password) + public Session restore(SavedSession savedSession) throws IOException, InterruptedException, AuthenticationException { - Object payload = new AuthenticatePayload(new Agent(agent), id, password); + RefreshPayload payload = new RefreshPayload(savedSession.getAccessToken(), clientId); - HttpRequest request = HttpRequest - .post(authUrl) + return call(new URL(this.authUrl, "/refresh"), payload, savedSession); + } + + private Session call(URL url, Object payload, SavedSession previous) + throws IOException, InterruptedException, AuthenticationException { + HttpRequest req = HttpRequest + .post(url) .bodyJson(payload) .execute(); - if (request.getResponseCode() != 200) { - ErrorResponse error = request.returnContent().asJson(ErrorResponse.class); - throw new AuthenticationException(error.getErrorMessage(), error.getErrorMessage()); + if (req.getResponseCode() != 200) { + ErrorResponse error = req.returnContent().asJson(ErrorResponse.class); + + throw new AuthenticationException(error.getErrorMessage()); } else { - AuthenticateResponse response = request.returnContent().asJson(AuthenticateResponse.class); - return response.getAvailableProfiles(); + AuthenticateResponse response = req.returnContent().asJson(AuthenticateResponse.class); + Profile profile = response.getSelectedProfile(); + + if (previous != null && previous.getAvatarImage() != null) { + profile.setAvatarImage(previous.getAvatarImage()); + } else { + profile.setAvatarImage(VisageSkinService.fetchSkinHead(profile.getUuid())); + } + + return profile; } } @@ -64,6 +77,14 @@ private final Agent agent; private final String username; private final String password; + private final String clientToken; + } + + @Data + private static class RefreshPayload { + private final String accessToken; + private final String clientToken; + private boolean requestUser = true; } @Data @@ -71,8 +92,7 @@ private static class AuthenticateResponse { private String accessToken; private String clientToken; - @JsonManagedReference private List availableProfiles; - private Profile selectedProfile; + @JsonManagedReference private Profile selectedProfile; } @Data @@ -92,6 +112,7 @@ @JsonProperty("id") private String uuid; private String name; private boolean legacy; + private byte[] avatarImage; @JsonIgnore private final Map userProperties = Collections.emptyMap(); @JsonBackReference private AuthenticateResponse response; @@ -103,12 +124,6 @@ @Override @JsonIgnore - public String getClientToken() { - return response.getClientToken(); - } - - @Override - @JsonIgnore public String getAccessToken() { return response.getAccessToken(); } diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/MicrosoftWebAuthorizer.java b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/MicrosoftWebAuthorizer.java new file mode 100644 index 0000000..eb07245 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/MicrosoftWebAuthorizer.java @@ -0,0 +1,58 @@ +package com.skcraft.launcher.auth.microsoft; + +import com.skcraft.launcher.auth.AuthenticationException; +import com.skcraft.launcher.util.HttpRequest; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.awt.*; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; + +/** + * Handles the Microsoft leg of OAuth authorization. + */ +@RequiredArgsConstructor +public class MicrosoftWebAuthorizer { + private final String clientId; + @Getter private String redirectUri; + + public OauthResult authorize() throws IOException, AuthenticationException, InterruptedException { + if (Desktop.isDesktopSupported()) { + // Interactive auth + return authorizeInteractive(); + } else { + // TODO Device code auth + return null; + } + } + + private OauthResult authorizeInteractive() throws IOException, AuthenticationException, InterruptedException { + OauthHttpHandler httpHandler = new OauthHttpHandler(); + Desktop.getDesktop().browse(generateInteractiveUrl(httpHandler.getPort())); + + return httpHandler.await(); + } + + private URI generateInteractiveUrl(int port) throws AuthenticationException { + redirectUri = "http://localhost:" + port; + + URI interactive; + try { + HttpRequest.Form query = HttpRequest.Form.form(); + query.add("client_id", clientId); + query.add("scope", "XboxLive.signin XboxLive.offline_access"); + query.add("response_type", "code"); + query.add("redirect_uri", redirectUri); + query.add("prompt", "select_account"); + + interactive = new URI("https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?" + + query.toString()); + } catch (URISyntaxException e) { + throw new AuthenticationException(e, "Failed to generate OAuth URL"); + } + + return interactive; + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/MinecraftServicesAuthorizer.java b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/MinecraftServicesAuthorizer.java new file mode 100644 index 0000000..1f8bc80 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/MinecraftServicesAuthorizer.java @@ -0,0 +1,48 @@ +package com.skcraft.launcher.auth.microsoft; + +import com.skcraft.launcher.auth.AuthenticationException; +import com.skcraft.launcher.auth.microsoft.model.*; +import com.skcraft.launcher.util.HttpRequest; +import com.skcraft.launcher.util.SharedLocale; + +import java.io.IOException; +import java.net.URL; + +import static com.skcraft.launcher.util.HttpRequest.url; + +public class MinecraftServicesAuthorizer { + private static final URL MC_SERVICES_LOGIN = url("https://api.minecraftservices.com/authentication/login_with_xbox"); + private static final URL MC_SERVICES_PROFILE = url("https://api.minecraftservices.com/minecraft/profile"); + + public static McAuthResponse authorizeWithMinecraft(XboxAuthorization auth) throws IOException, InterruptedException { + McAuthRequest request = new McAuthRequest("XBL3.0 x=" + auth.getCombinedToken()); + + return HttpRequest.post(MC_SERVICES_LOGIN) + .bodyJson(request) + .header("Accept", "application/json") + .execute() + .expectResponseCode(200) + .returnContent() + .asJson(McAuthResponse.class); + } + + public static McProfileResponse getUserProfile(McAuthResponse auth) + throws IOException, InterruptedException, AuthenticationException { + return HttpRequest.get(MC_SERVICES_PROFILE) + .header("Authorization", auth.getAuthorization()) + .execute() + .expectResponseCodeOr(200, req -> { + McServicesError error = req.returnContent().asJson(McServicesError.class); + + if (error.getError().equals("NOT_FOUND")) { + return new AuthenticationException("No Minecraft profile", + SharedLocale.tr("login.minecraftNotOwnedError")); + } + + return new AuthenticationException(error.getErrorMessage(), + SharedLocale.tr("login.minecraft.error", error.getErrorMessage())); + }) + .returnContent() + .asJson(McProfileResponse.class); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/OauthHttpHandler.java b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/OauthHttpHandler.java new file mode 100644 index 0000000..b7b1e0f --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/OauthHttpHandler.java @@ -0,0 +1,66 @@ +package com.skcraft.launcher.auth.microsoft; + +import com.google.common.base.Charsets; +import com.google.common.base.Splitter; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import lombok.extern.java.Log; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.util.Map; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +@Log +public class OauthHttpHandler { + private Executor executor = Executors.newCachedThreadPool(); + private HttpServer server; + private OauthResult result; + + public OauthHttpHandler() throws IOException { + server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + + server.createContext("/", new Handler()); + server.setExecutor(executor); + server.start(); + } + + public int getPort() { + return server.getAddress().getPort(); + } + + public OauthResult await() throws InterruptedException { + synchronized (this) { + this.wait(); + } + + server.stop(3); + + return result; + } + + private class Handler implements HttpHandler { + @Override + public void handle(HttpExchange httpExchange) throws IOException { + String query = httpExchange.getRequestURI().getQuery(); + Map qs = Splitter.on('&').withKeyValueSeparator('=').split(query); + if (qs.get("error") != null) { + result = new OauthResult.Error(qs.get("error_description")); + } else { + result = new OauthResult.Success(qs.get("code")); + } + + synchronized (OauthHttpHandler.this) { + OauthHttpHandler.this.notifyAll(); + } + + byte[] response = "OK: you can close the browser now".getBytes(Charsets.UTF_8); + httpExchange.sendResponseHeaders(200, response.length); + httpExchange.getResponseBody().write(response); + httpExchange.getResponseBody().flush(); + httpExchange.getResponseBody().close(); + } + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/OauthResult.java b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/OauthResult.java new file mode 100644 index 0000000..28c8001 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/OauthResult.java @@ -0,0 +1,28 @@ +package com.skcraft.launcher.auth.microsoft; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +public interface OauthResult { + boolean isError(); + + @RequiredArgsConstructor + class Success implements OauthResult { + @Getter private final String authCode; + + @Override + public boolean isError() { + return false; + } + } + + @RequiredArgsConstructor + class Error implements OauthResult { + @Getter private final String errorMessage; + + @Override + public boolean isError() { + return true; + } + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/XboxTokenAuthorizer.java b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/XboxTokenAuthorizer.java new file mode 100644 index 0000000..8798b87 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/XboxTokenAuthorizer.java @@ -0,0 +1,65 @@ +package com.skcraft.launcher.auth.microsoft; + +import com.skcraft.launcher.auth.AuthenticationException; +import com.skcraft.launcher.auth.microsoft.model.*; +import com.skcraft.launcher.util.HttpRequest; +import com.skcraft.launcher.util.SharedLocale; + +import java.io.IOException; +import java.net.URL; + +import static com.skcraft.launcher.util.HttpRequest.url; + +public class XboxTokenAuthorizer { + private static final URL XBL_AUTHENTICATE_URL = url("https://user.auth.xboxlive.com/user/authenticate"); + private static final URL XSTS_AUTHENTICATE_URL = url("https://xsts.auth.xboxlive.com/xsts/authorize"); + + public static XboxAuthorization authorizeWithXbox(String accessToken) + throws IOException, InterruptedException, AuthenticationException { + XboxAuthRequest xblPayload = + new XboxAuthRequest<>(new XblAuthProperties("d=" + accessToken)); + + XboxAuthResponse xblResponse = HttpRequest.post(XBL_AUTHENTICATE_URL) + .bodyJson(xblPayload) + .header("Accept", "application/json") + .execute() + .expectResponseCodeOr(200, (req) -> + new AuthenticationException("Error authenticating with Xbox Live", + SharedLocale.tr("login.xbox.generic"))) + .returnContent() + .asJson(XboxAuthResponse.class); + + XboxAuthRequest xstsPayload = + new XboxAuthRequest<>(new XstsAuthProperties(xblResponse.getToken())); + xstsPayload.setRelyingParty("rp://api.minecraftservices.com/"); + + XboxAuthResponse xstsResponse = HttpRequest.post(XSTS_AUTHENTICATE_URL) + .bodyJson(xstsPayload) + .header("Accept", "application/json") + .execute() + .expectResponseCodeOr(200, (req) -> { + XstsError xstsError = req.returnContent().asJson(XstsError.class); + + return new AuthenticationException(xstsError.getMessage(), getErrorMessage(xstsError)); + }) + .returnContent() + .asJson(XboxAuthResponse.class); + + return new XboxAuthorization(xstsResponse.getToken(), xstsResponse.getUhs()); + } + + private static String getErrorMessage(XstsError xstsError) { + long xboxErrorCode = xstsError.getXErr(); + if (xboxErrorCode == 2148916233L) { + return SharedLocale.tr("login.xbox.noXboxAccount"); + } + if (xboxErrorCode == 2148916238L) { + return SharedLocale.tr("login.xbox.isChild"); + } + if (!xstsError.getMessage().isEmpty()) { + return SharedLocale.tr("login.xbox.errorMessage", xstsError.getMessage()); + } + + return SharedLocale.tr("login.xbox.unknown", xboxErrorCode); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/McAuthRequest.java b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/McAuthRequest.java new file mode 100644 index 0000000..505da69 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/McAuthRequest.java @@ -0,0 +1,8 @@ +package com.skcraft.launcher.auth.microsoft.model; + +import lombok.Data; + +@Data +public class McAuthRequest { + private final String identityToken; +} diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/McAuthResponse.java b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/McAuthResponse.java new file mode 100644 index 0000000..768c05f --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/McAuthResponse.java @@ -0,0 +1,21 @@ +package com.skcraft.launcher.auth.microsoft.model; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Data; + +@Data +@JsonNaming(PropertyNamingStrategy.LowerCaseWithUnderscoresStrategy.class) +@JsonIgnoreProperties(ignoreUnknown = true) +public class McAuthResponse { + private String accessToken; + private String tokenType; + private int expiresIn; + + @JsonIgnore + public String getAuthorization() { + return String.format("%s %s", tokenType, accessToken); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/McProfileResponse.java b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/McProfileResponse.java new file mode 100644 index 0000000..7c22645 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/McProfileResponse.java @@ -0,0 +1,12 @@ +package com.skcraft.launcher.auth.microsoft.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class McProfileResponse { + @JsonProperty("id") private String uuid; + private String name; +} diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/McServicesError.java b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/McServicesError.java new file mode 100644 index 0000000..034f05d --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/McServicesError.java @@ -0,0 +1,11 @@ +package com.skcraft.launcher.auth.microsoft.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class McServicesError { + private String error; + private String errorMessage; +} diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/TokenResponse.java b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/TokenResponse.java new file mode 100644 index 0000000..d6aeb60 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/TokenResponse.java @@ -0,0 +1,15 @@ +package com.skcraft.launcher.auth.microsoft.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Data; + +@Data +@JsonNaming(PropertyNamingStrategy.LowerCaseWithUnderscoresStrategy.class) +@JsonIgnoreProperties(ignoreUnknown = true) +public class TokenResponse { + private String tokenType; + private String accessToken; + private String refreshToken; +} diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/XblAuthProperties.java b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/XblAuthProperties.java new file mode 100644 index 0000000..1414687 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/XblAuthProperties.java @@ -0,0 +1,15 @@ +package com.skcraft.launcher.auth.microsoft.model; + +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Data; +import lombok.NonNull; + +@Data +@JsonNaming(PropertyNamingStrategy.PascalCaseStrategy.class) +public class XblAuthProperties { + private String authMethod = "RPS"; + private String siteName = "user.auth.xboxlive.com"; + @NonNull + private String rpsTicket; +} diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/XboxAuthRequest.java b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/XboxAuthRequest.java new file mode 100644 index 0000000..53c4498 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/XboxAuthRequest.java @@ -0,0 +1,14 @@ +package com.skcraft.launcher.auth.microsoft.model; + +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Data; +import lombok.NonNull; + +@Data +@JsonNaming(PropertyNamingStrategy.PascalCaseStrategy.class) +public class XboxAuthRequest { + @NonNull private T properties; + private String relyingParty = "http://auth.xboxlive.com"; + private String tokenType = "JWT"; +} diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/XboxAuthResponse.java b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/XboxAuthResponse.java new file mode 100644 index 0000000..c7893a8 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/XboxAuthResponse.java @@ -0,0 +1,32 @@ +package com.skcraft.launcher.auth.microsoft.model; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Data; + +import java.util.List; + +@Data +@JsonNaming(PropertyNamingStrategy.PascalCaseStrategy.class) +@JsonIgnoreProperties(ignoreUnknown = true) +public class XboxAuthResponse { + private String token; + private DisplayClaims displayClaims; + + @JsonIgnore + public String getUhs() { + return getDisplayClaims().getXui().get(0).getUhs(); + } + + @Data + public static class DisplayClaims { + private List xui; + } + + @Data + public static class UhsContainer { + private String uhs; + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/XboxAuthorization.java b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/XboxAuthorization.java new file mode 100644 index 0000000..499a0e6 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/XboxAuthorization.java @@ -0,0 +1,13 @@ +package com.skcraft.launcher.auth.microsoft.model; + +import lombok.Data; + +@Data +public class XboxAuthorization { + private final String token; + private final String uhs; + + public String getCombinedToken() { + return String.format("%s;%s", uhs, token); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/XstsAuthProperties.java b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/XstsAuthProperties.java new file mode 100644 index 0000000..63fcef8 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/XstsAuthProperties.java @@ -0,0 +1,19 @@ +package com.skcraft.launcher.auth.microsoft.model; + +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Data; + +import java.util.Collections; +import java.util.List; + +@Data +@JsonNaming(PropertyNamingStrategy.PascalCaseStrategy.class) +public class XstsAuthProperties { + private String sandboxId = "RETAIL"; + private List userTokens; + + public XstsAuthProperties(String token) { + this.userTokens = Collections.singletonList(token); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/XstsError.java b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/XstsError.java new file mode 100644 index 0000000..d279d42 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/XstsError.java @@ -0,0 +1,17 @@ +package com.skcraft.launcher.auth.microsoft.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Data; + +@Data +@JsonNaming(PropertyNamingStrategy.PascalCaseStrategy.class) +@JsonIgnoreProperties(ignoreUnknown = true) +public class XstsError { + @JsonProperty("XErr") + private long xErr; + private String message; + private String redirect; +} diff --git a/launcher/src/main/java/com/skcraft/launcher/dialog/AccountSelectDialog.java b/launcher/src/main/java/com/skcraft/launcher/dialog/AccountSelectDialog.java new file mode 100644 index 0000000..7b34a58 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/dialog/AccountSelectDialog.java @@ -0,0 +1,242 @@ +package com.skcraft.launcher.dialog; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.skcraft.concurrency.ObservableFuture; +import com.skcraft.concurrency.ProgressObservable; +import com.skcraft.concurrency.SettableProgress; +import com.skcraft.launcher.Launcher; +import com.skcraft.launcher.auth.LoginService; +import com.skcraft.launcher.auth.OfflineSession; +import com.skcraft.launcher.auth.SavedSession; +import com.skcraft.launcher.auth.Session; +import com.skcraft.launcher.persistence.Persistence; +import com.skcraft.launcher.swing.LinedBoxPanel; +import com.skcraft.launcher.swing.SwingHelper; +import com.skcraft.launcher.util.SharedLocale; +import com.skcraft.launcher.util.SwingExecutor; +import lombok.RequiredArgsConstructor; + +import javax.swing.*; +import java.awt.*; +import java.util.concurrent.Callable; + +public class AccountSelectDialog extends JDialog { + private final JList accountList; + private final JButton loginButton = new JButton(SharedLocale.tr("accounts.play")); + private final JButton cancelButton = new JButton(SharedLocale.tr("button.cancel")); + private final JButton addMojangButton = new JButton(SharedLocale.tr("accounts.addMojang")); + private final JButton addMicrosoftButton = new JButton(SharedLocale.tr("accounts.addMicrosoft")); + private final JButton removeSelected = new JButton(SharedLocale.tr("accounts.removeSelected")); + private final JButton offlineButton = new JButton(SharedLocale.tr("login.playOffline")); + private final LinedBoxPanel buttonsPanel = new LinedBoxPanel(true); + + private final Launcher launcher; + private Session selected; + + public AccountSelectDialog(Window owner, Launcher launcher) { + super(owner, ModalityType.DOCUMENT_MODAL); + + this.launcher = launcher; + this.accountList = new JList<>(launcher.getAccounts()); + + setTitle(SharedLocale.tr("accounts.title")); + initComponents(); + setDefaultCloseOperation(DISPOSE_ON_CLOSE); + setMinimumSize(new Dimension(350, 170)); + setResizable(false); + pack(); + setLocationRelativeTo(owner); + } + + private void initComponents() { + setLayout(new BorderLayout()); + + accountList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + accountList.setLayoutOrientation(JList.VERTICAL); + accountList.setVisibleRowCount(0); + accountList.setCellRenderer(new AccountRenderer()); + + JScrollPane accountPane = new JScrollPane(accountList); + accountPane.setPreferredSize(new Dimension(250, 100)); + accountPane.setAlignmentX(CENTER_ALIGNMENT); + + loginButton.setFont(loginButton.getFont().deriveFont(Font.BOLD)); + loginButton.setMargin(new Insets(0, 10, 0, 10)); + + buttonsPanel.setBorder(BorderFactory.createEmptyBorder(26, 13, 13, 13)); + if (launcher.getConfig().isOfflineEnabled()) { + buttonsPanel.addElement(offlineButton); + } + buttonsPanel.addGlue(); + buttonsPanel.addElement(loginButton); + buttonsPanel.addElement(cancelButton); + + LinedBoxPanel loginButtonsRow = new LinedBoxPanel(true); + loginButtonsRow.add(addMojangButton); + loginButtonsRow.add(addMicrosoftButton); + loginButtonsRow.addGlue(); + loginButtonsRow.add(removeSelected); + loginButtonsRow.setAlignmentX(CENTER_ALIGNMENT); + loginButtonsRow.setBorder(null); + + JPanel listPane = new JPanel(); + listPane.setLayout(new BoxLayout(listPane, BoxLayout.Y_AXIS)); + listPane.add(accountPane); + listPane.add(Box.createVerticalStrut(5)); + listPane.add(loginButtonsRow); + listPane.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); + listPane.setAlignmentX(CENTER_ALIGNMENT); + + add(listPane, BorderLayout.CENTER); + add(buttonsPanel, BorderLayout.SOUTH); + + loginButton.addActionListener(ev -> attemptExistingLogin(accountList.getSelectedValue())); + cancelButton.addActionListener(ev -> dispose()); + + addMojangButton.addActionListener(ev -> { + Session newSession = LoginDialog.showLoginRequest(this, launcher); + + if (newSession != null) { + launcher.getAccounts().add(newSession.toSavedSession()); + setResult(newSession); + } + }); + + addMicrosoftButton.addActionListener(ev -> attemptMicrosoftLogin()); + + offlineButton.addActionListener(ev -> + setResult(new OfflineSession(launcher.getProperties().getProperty("offlinePlayerName")))); + + removeSelected.addActionListener(ev -> { + if (accountList.getSelectedValue() != null) { + boolean confirmed = SwingHelper.confirmDialog(this, SharedLocale.tr("accounts.confirmForget"), + SharedLocale.tr("accounts.confirmForgetTitle")); + + if (confirmed) { + launcher.getAccounts().remove(accountList.getSelectedValue()); + } + } + }); + + accountList.setSelectedIndex(0); + } + + @Override + public void dispose() { + accountList.setModel(new DefaultListModel<>()); + super.dispose(); + } + + public static Session showAccountRequest(Window owner, Launcher launcher) { + AccountSelectDialog dialog = new AccountSelectDialog(owner, launcher); + dialog.setVisible(true); + + if (dialog.selected != null && dialog.selected.isOnline()) { + launcher.getAccounts().update(dialog.selected.toSavedSession()); + } + + Persistence.commitAndForget(launcher.getAccounts()); + + return dialog.selected; + } + + private void setResult(Session result) { + this.selected = result; + dispose(); + } + + private void attemptMicrosoftLogin() { + String status = SharedLocale.tr("login.microsoft.seeBrowser"); + SettableProgress progress = new SettableProgress(status, -1); + + ListenableFuture future = launcher.getExecutor().submit(() -> { + Session newSession = launcher.getMicrosoftLogin().login(() -> + progress.set(SharedLocale.tr("login.loggingInStatus"), -1)); + + if (newSession != null) { + launcher.getAccounts().add(newSession.toSavedSession()); + setResult(newSession); + } + + return null; + }); + + ProgressDialog.showProgress(this, future, progress, + SharedLocale.tr("login.loggingInTitle"), status); + SwingHelper.addErrorDialogCallback(this, future); + } + + private void attemptExistingLogin(SavedSession session) { + if (session == null) return; + + LoginService loginService = launcher.getLoginService(session.getType()); + RestoreSessionCallable callable = new RestoreSessionCallable(loginService, session); + + ObservableFuture future = new ObservableFuture<>(launcher.getExecutor().submit(callable), callable); + Futures.addCallback(future, new FutureCallback() { + @Override + public void onSuccess(Session result) { + setResult(result); + } + + @Override + public void onFailure(Throwable t) { + t.printStackTrace(); + } + }, SwingExecutor.INSTANCE); + + ProgressDialog.showProgress(this, future, SharedLocale.tr("login.loggingInTitle"), + SharedLocale.tr("login.loggingInStatus")); + SwingHelper.addErrorDialogCallback(this, future); + } + + @RequiredArgsConstructor + private static class RestoreSessionCallable implements Callable, ProgressObservable { + private final LoginService service; + private final SavedSession session; + + @Override + public Session call() throws Exception { + return service.restore(session); + } + + @Override + public String getStatus() { + return SharedLocale.tr("accounts.refreshingStatus"); + } + + @Override + public double getProgress() { + return -1; + } + } + + private static class AccountRenderer extends JLabel implements ListCellRenderer { + public AccountRenderer() { + setHorizontalAlignment(CENTER); + } + + @Override + public Component getListCellRendererComponent(JList list, SavedSession value, int index, boolean isSelected, boolean cellHasFocus) { + setText(value.getUsername()); + if (value.getAvatarImage() != null) { + setIcon(new ImageIcon(value.getAvatarImage())); + } else { + setIcon(SwingHelper.createIcon(Launcher.class, "default_skin.png", 32, 32)); + } + + if (isSelected) { + setOpaque(true); + setBackground(list.getSelectionBackground()); + setForeground(list.getSelectionForeground()); + } else { + setOpaque(false); + setForeground(list.getForeground()); + } + + return this; + } + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/dialog/LoginDialog.java b/launcher/src/main/java/com/skcraft/launcher/dialog/LoginDialog.java index 41a6611..e93abe4 100644 --- a/launcher/src/main/java/com/skcraft/launcher/dialog/LoginDialog.java +++ b/launcher/src/main/java/com/skcraft/launcher/dialog/LoginDialog.java @@ -6,27 +6,28 @@ package com.skcraft.launcher.dialog; -import com.google.common.base.Strings; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.skcraft.concurrency.ObservableFuture; import com.skcraft.concurrency.ProgressObservable; import com.skcraft.launcher.Configuration; import com.skcraft.launcher.Launcher; -import com.skcraft.launcher.auth.*; -import com.skcraft.launcher.swing.*; +import com.skcraft.launcher.auth.AuthenticationException; +import com.skcraft.launcher.auth.Session; +import com.skcraft.launcher.auth.YggdrasilLoginService; import com.skcraft.launcher.persistence.Persistence; +import com.skcraft.launcher.swing.*; import com.skcraft.launcher.util.SharedLocale; import com.skcraft.launcher.util.SwingExecutor; import lombok.Getter; import lombok.NonNull; +import lombok.RequiredArgsConstructor; import javax.swing.*; import java.awt.*; -import java.awt.event.*; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; import java.io.IOException; -import java.util.Date; -import java.util.List; import java.util.concurrent.Callable; /** @@ -35,16 +36,12 @@ public class LoginDialog extends JDialog { private final Launcher launcher; - @Getter private final AccountList accounts; @Getter private Session session; - private final JComboBox idCombo = new JComboBox(); + private final JTextField usernameText = new JTextField(); private final JPasswordField passwordText = new JPasswordField(); - private final JCheckBox rememberIdCheck = new JCheckBox(SharedLocale.tr("login.rememberId")); - private final JCheckBox rememberPassCheck = new JCheckBox(SharedLocale.tr("login.rememberPassword")); private final JButton loginButton = new JButton(SharedLocale.tr("login.login")); private final LinkButton recoverButton = new LinkButton(SharedLocale.tr("login.recoverAccount")); - private final JButton offlineButton = new JButton(SharedLocale.tr("login.playOffline")); private final JButton cancelButton = new JButton(SharedLocale.tr("button.cancel")); private final FormPanel formPanel = new FormPanel(); private final LinedBoxPanel buttonsPanel = new LinedBoxPanel(true); @@ -59,7 +56,6 @@ super(owner, ModalityType.DOCUMENT_MODAL); this.launcher = launcher; - this.accounts = launcher.getAccounts(); setTitle(SharedLocale.tr("login.title")); initComponents(); @@ -73,39 +69,21 @@ addWindowListener(new WindowAdapter() { @Override public void windowClosing(WindowEvent event) { - removeListeners(); dispose(); } }); } @SuppressWarnings("unchecked") - private void removeListeners() { - idCombo.setModel(new DefaultComboBoxModel()); - } - - @SuppressWarnings("unchecked") private void initComponents() { - idCombo.setModel(getAccounts()); - updateSelection(); - - rememberIdCheck.setBorder(BorderFactory.createEmptyBorder()); - rememberPassCheck.setBorder(BorderFactory.createEmptyBorder()); - idCombo.setEditable(true); - idCombo.getEditor().selectAll(); + usernameText.setEditable(true); loginButton.setFont(loginButton.getFont().deriveFont(Font.BOLD)); - formPanel.addRow(new JLabel(SharedLocale.tr("login.idEmail")), idCombo); + formPanel.addRow(new JLabel(SharedLocale.tr("login.idEmail")), usernameText); formPanel.addRow(new JLabel(SharedLocale.tr("login.password")), passwordText); - formPanel.addRow(new JLabel(), rememberIdCheck); - formPanel.addRow(new JLabel(), rememberPassCheck); buttonsPanel.setBorder(BorderFactory.createEmptyBorder(26, 13, 13, 13)); - if (launcher.getConfig().isOfflineEnabled()) { - buttonsPanel.addElement(offlineButton); - buttonsPanel.addElement(Box.createHorizontalStrut(2)); - } buttonsPanel.addElement(recoverButton); buttonsPanel.addGlue(); buttonsPanel.addElement(loginButton); @@ -118,171 +96,30 @@ passwordText.setComponentPopupMenu(TextFieldPopupMenu.INSTANCE); - idCombo.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent e) { - updateSelection(); - } - }); - - idCombo.getEditor().getEditorComponent().addMouseListener(new PopupMouseAdapter() { - @Override - protected void showPopup(MouseEvent e) { - popupManageMenu(e.getComponent(), e.getX(), e.getY()); - } - }); - recoverButton.addActionListener( ActionListeners.openURL(recoverButton, launcher.getProperties().getProperty("resetPasswordUrl"))); - loginButton.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent e) { - prepareLogin(); - } - }); - - offlineButton.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent e) { - setResult(new OfflineSession(launcher.getProperties().getProperty("offlinePlayerName"))); - removeListeners(); - dispose(); - } - }); - - cancelButton.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent e) { - removeListeners(); - dispose(); - } - }); - - rememberPassCheck.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent e) { - if (rememberPassCheck.isSelected()) { - rememberIdCheck.setSelected(true); - } - } - }); - - rememberIdCheck.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent e) { - if (!rememberIdCheck.isSelected()) { - rememberPassCheck.setSelected(false); - } - } - }); - } - - private void popupManageMenu(Component component, int x, int y) { - Object selected = idCombo.getSelectedItem(); - JPopupMenu popup = new JPopupMenu(); - JMenuItem menuItem; - - if (selected != null && selected instanceof Account) { - final Account account = (Account) selected; - - menuItem = new JMenuItem(SharedLocale.tr("login.forgetUser")); - menuItem.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent e) { - accounts.remove(account); - Persistence.commitAndForget(accounts); - } - }); - popup.add(menuItem); - - if (!Strings.isNullOrEmpty(account.getPassword())) { - menuItem = new JMenuItem(SharedLocale.tr("login.forgetPassword")); - menuItem.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent e) { - account.setPassword(null); - Persistence.commitAndForget(accounts); - } - }); - popup.add(menuItem); - } - } - - menuItem = new JMenuItem(SharedLocale.tr("login.forgetAllPasswords")); - menuItem.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent e) { - if (SwingHelper.confirmDialog(LoginDialog.this, - SharedLocale.tr("login.confirmForgetAllPasswords"), - SharedLocale.tr("login.forgetAllPasswordsTitle"))) { - accounts.forgetPasswords(); - Persistence.commitAndForget(accounts); - } - } - }); - popup.add(menuItem); - - popup.show(component, x, y); - } - - private void updateSelection() { - Object selected = idCombo.getSelectedItem(); - - if (selected != null && selected instanceof Account) { - Account account = (Account) selected; - String password = account.getPassword(); - - rememberIdCheck.setSelected(true); - if (!Strings.isNullOrEmpty(password)) { - rememberPassCheck.setSelected(true); - passwordText.setText(password); - } else { - rememberPassCheck.setSelected(false); - } - } else { - passwordText.setText(""); - rememberIdCheck.setSelected(true); - rememberPassCheck.setSelected(false); - } + loginButton.addActionListener(e -> prepareLogin()); + cancelButton.addActionListener(e -> dispose()); } @SuppressWarnings("deprecation") private void prepareLogin() { - Object selected = idCombo.getSelectedItem(); - - if (selected != null && selected instanceof Account) { - Account account = (Account) selected; + if (!usernameText.getText().isEmpty()) { String password = passwordText.getText(); if (password == null || password.isEmpty()) { SwingHelper.showErrorDialog(this, SharedLocale.tr("login.noPasswordError"), SharedLocale.tr("login.noPasswordTitle")); } else { - if (rememberPassCheck.isSelected()) { - account.setPassword(password); - } else { - account.setPassword(null); - } - - if (rememberIdCheck.isSelected()) { - accounts.add(account); - } else { - accounts.remove(account); - } - - account.setLastUsed(new Date()); - - Persistence.commitAndForget(accounts); - - attemptLogin(account, password); + attemptLogin(usernameText.getText(), password); } } else { SwingHelper.showErrorDialog(this, SharedLocale.tr("login.noLoginError"), SharedLocale.tr("login.noLoginTitle")); } } - private void attemptLogin(Account account, String password) { - LoginCallable callable = new LoginCallable(account, password); + private void attemptLogin(String username, String password) { + LoginCallable callable = new LoginCallable(username, password); ObservableFuture future = new ObservableFuture( launcher.getExecutor().submit(callable), callable); @@ -303,7 +140,6 @@ private void setResult(Session session) { this.session = session; - removeListeners(); dispose(); } @@ -313,23 +149,19 @@ return dialog.getSession(); } - private class LoginCallable implements Callable,ProgressObservable { - private final Account account; + @RequiredArgsConstructor + private class LoginCallable implements Callable, ProgressObservable { + private final String username; private final String password; - private LoginCallable(Account account, String password) { - this.account = account; - this.password = password; - } - @Override public Session call() throws AuthenticationException, IOException, InterruptedException { - LoginService service = launcher.getLoginService(); - List identities = service.login(launcher.getProperties().getProperty("agentName"), account.getId(), password); + YggdrasilLoginService service = launcher.getYggdrasil(); + Session identity = service.login(username, password); - // The list of identities (profiles in Mojang terms) corresponds to whether the account + // The presence of the identity (profile in Mojang terms) corresponds to whether the account // owns the game, so we need to check that - if (identities.size() > 0) { + if (identity != null) { // Set offline enabled flag to true Configuration config = launcher.getConfig(); if (!config.isOfflineEnabled()) { @@ -337,8 +169,7 @@ Persistence.commitAndForget(config); } - Persistence.commitAndForget(getAccounts()); - return identities.get(0); + return identity; } else { throw new AuthenticationException("Minecraft not owned", SharedLocale.tr("login.minecraftNotOwnedError")); } diff --git a/launcher/src/main/java/com/skcraft/launcher/dialog/component/ListListenerReducer.java b/launcher/src/main/java/com/skcraft/launcher/dialog/component/ListListenerReducer.java new file mode 100644 index 0000000..acbddba --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/dialog/component/ListListenerReducer.java @@ -0,0 +1,34 @@ +package com.skcraft.launcher.dialog.component; + +import com.beust.jcommander.internal.Lists; + +import javax.swing.event.ListDataEvent; +import javax.swing.event.ListDataListener; +import java.util.List; + +public class ListListenerReducer implements ListDataListener { + private final List listeners = Lists.newArrayList(); + + @Override + public void intervalAdded(ListDataEvent e) { + listeners.forEach(it -> it.intervalAdded(e)); + } + + @Override + public void intervalRemoved(ListDataEvent e) { + listeners.forEach(it -> it.intervalRemoved(e)); + } + + @Override + public void contentsChanged(ListDataEvent e) { + listeners.forEach(it -> it.contentsChanged(e)); + } + + public void addListDataListener(ListDataListener l) { + listeners.add(l); + } + + public void removeListDataListener(ListDataListener l) { + listeners.remove(l); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/launch/LaunchSupervisor.java b/launcher/src/main/java/com/skcraft/launcher/launch/LaunchSupervisor.java index 809ff34..9dd8a27 100644 --- a/launcher/src/main/java/com/skcraft/launcher/launch/LaunchSupervisor.java +++ b/launcher/src/main/java/com/skcraft/launcher/launch/LaunchSupervisor.java @@ -13,7 +13,7 @@ import com.skcraft.launcher.Instance; import com.skcraft.launcher.Launcher; import com.skcraft.launcher.auth.Session; -import com.skcraft.launcher.dialog.LoginDialog; +import com.skcraft.launcher.dialog.AccountSelectDialog; import com.skcraft.launcher.dialog.ProgressDialog; import com.skcraft.launcher.launch.LaunchOptions.UpdatePolicy; import com.skcraft.launcher.persistence.Persistence; @@ -61,7 +61,7 @@ if (options.getSession() != null) { session = options.getSession(); } else { - session = LoginDialog.showLoginRequest(window, launcher); + session = AccountSelectDialog.showAccountRequest(window, launcher); if (session == null) { return; } diff --git a/launcher/src/main/java/com/skcraft/launcher/util/HttpFunction.java b/launcher/src/main/java/com/skcraft/launcher/util/HttpFunction.java new file mode 100644 index 0000000..67788e8 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/util/HttpFunction.java @@ -0,0 +1,8 @@ +package com.skcraft.launcher.util; + +import java.io.IOException; + +@FunctionalInterface +public interface HttpFunction { + V call(T arg) throws IOException, InterruptedException; +} diff --git a/launcher/src/main/java/com/skcraft/launcher/util/HttpRequest.java b/launcher/src/main/java/com/skcraft/launcher/util/HttpRequest.java index a38dee3..bef8ea9 100644 --- a/launcher/src/main/java/com/skcraft/launcher/util/HttpRequest.java +++ b/launcher/src/main/java/com/skcraft/launcher/util/HttpRequest.java @@ -203,6 +203,27 @@ } /** + * Continue if the response code matches, otherwise call the provided function + * to generate an exception. + * + * @param code HTTP status code to continue on. + * @param onError Function invoked when the code does not match, should return an error that will be thrown. + * @return this object if successful + * @throws Exception either an {@link IOException} on I/O error or a user-defined {@link Exception} subclass + * if the code does not match. + */ + public HttpRequest expectResponseCodeOr(int code, HttpFunction onError) + throws E, IOException, InterruptedException { + int responseCode = getResponseCode(); + + if (code == responseCode) return this; + + E exc = onError.call(this); + close(); + throw exc; + } + + /** * Get the response code. * * @return the response code diff --git a/launcher/src/main/resources/com/skcraft/launcher/default_skin.png b/launcher/src/main/resources/com/skcraft/launcher/default_skin.png new file mode 100644 index 0000000..4c885f1 --- /dev/null +++ b/launcher/src/main/resources/com/skcraft/launcher/default_skin.png Binary files differ diff --git a/launcher/src/main/resources/com/skcraft/launcher/lang/Launcher.properties b/launcher/src/main/resources/com/skcraft/launcher/lang/Launcher.properties index 23b54eb..085d65e 100644 --- a/launcher/src/main/resources/com/skcraft/launcher/lang/Launcher.properties +++ b/launcher/src/main/resources/com/skcraft/launcher/lang/Launcher.properties @@ -87,27 +87,37 @@ launcher.requiresUpdateHint=(requires update) launcher.updatePendingHint=(pending update) -login.rememberId=Remember my account in the list -login.rememberPassword=Remember my password +accounts.title=Select the account to play with +accounts.play=Play! +accounts.refreshingStatus=Refreshing login session... +accounts.addMojang=Add Mojang account +accounts.addMicrosoft=Add Microsoft account +accounts.removeSelected=Forget selected account +accounts.confirmForgetTitle=Forget account +accounts.confirmForget=Are you sure that you want to forget that account? + login.login=Login... login.recoverAccount=Forgot your login? login.playOffline=Play offline login.title=Minecraft Login login.idEmail=ID/Email\: login.password=Password\: -login.forgetUser=Forget selected user -login.forgetPassword=Forget password -login.forgetAllPasswords=Forget all passwords... -login.confirmForgetAllPasswords=Are you sure that you want to forget all saved passwords? -login.forgetAllPasswordsTitle=Forget passwords login.noPasswordError=Please enter a password. login.noPasswordTitle=Missing Password login.loggingInTitle=Logging in... -login.loggingInStatus=Logging in to Mojang... +login.loggingInStatus=Logging in to Minecraft... login.noLoginError=Please enter your account details. login.noLoginTitle=Missing Account login.minecraftNotOwnedError=Sorry, Minecraft is not owned on that account. +login.microsoft.seeBrowser=Check your browser to login with Microsoft. +login.xbox.generic=Failed to authenticate with Xbox Live. +login.xbox.noXboxAccount=That account does not have an Xbox account associated! +login.xbox.isChild=The account is a child (under 18) and cannot proceed unless it is part of a Family. +login.xbox.unknown=An unknown error occurred while logging in with Xbox (XErr {0}) +login.xbox.errorMessage=An unknown error occurred while logging in with Xbox: {0} +login.minecraft.error=An error occurred while authorizing with Minecraft services: {0} + console.title=Messages and Errors console.launcherConsoleTitle=Launcher Messages console.uploadLog=Upload Log diff --git a/launcher/src/main/resources/com/skcraft/launcher/launcher.properties b/launcher/src/main/resources/com/skcraft/launcher/launcher.properties index 67119ca..0a81e78 100644 --- a/launcher/src/main/resources/com/skcraft/launcher/launcher.properties +++ b/launcher/src/main/resources/com/skcraft/launcher/launcher.properties @@ -5,7 +5,6 @@ # version=${project.version} -agentName=Minecraft launcherShortname=SKCLauncher offlinePlayerName=Player @@ -13,6 +12,7 @@ librariesSource=https://libraries.minecraft.net/ assetsSource=http://resources.download.minecraft.net/ yggdrasilAuthUrl=https://authserver.mojang.com/authenticate +microsoftClientId=d18bb4d8-a27f-4451-a87f-fe6de4436813 resetPasswordUrl=https://minecraft.net/resetpassword newsUrl=http://update.skcraft.com/template/news.html?version=%s