From 72152e378f6f953522d1aa1721d94220f2c7e6dd Mon Sep 17 00:00:00 2001 From: awruff Date: Tue, 21 Jul 2026 03:24:21 -0400 Subject: [PATCH 1/4] [wip] feat: Account Switcher Basic integration with OneClient's Launcher --- .../client/access/MinecraftAccessor.java | 12 + .../mixin/client/access/UserAccessor.java | 23 + .../client/gui/PolyPlusMainMenuScreen.kt | 563 ++++++++++++++++-- .../polyplus/client/launcher/AccountSwitch.kt | 33 + .../client/launcher/LauncherAccountStore.kt | 121 ++++ .../polyplus/client/launcher/MicrosoftAuth.kt | 348 +++++++++++ .../client/launcher/OneLauncherAccounts.kt | 102 ++++ .../assets/polyplus/mainmenu/check-circle.svg | 5 + .../assets/polyplus/mainmenu/trash-01.svg | 5 + src/main/resources/mixins.polyplus.json | 73 +-- 10 files changed, 1166 insertions(+), 119 deletions(-) create mode 100644 src/main/java/org/polyfrost/polyplus/mixin/client/access/MinecraftAccessor.java create mode 100644 src/main/java/org/polyfrost/polyplus/mixin/client/access/UserAccessor.java create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/launcher/AccountSwitch.kt create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/launcher/LauncherAccountStore.kt create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/launcher/MicrosoftAuth.kt create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/launcher/OneLauncherAccounts.kt create mode 100644 src/main/resources/assets/polyplus/mainmenu/check-circle.svg create mode 100644 src/main/resources/assets/polyplus/mainmenu/trash-01.svg diff --git a/src/main/java/org/polyfrost/polyplus/mixin/client/access/MinecraftAccessor.java b/src/main/java/org/polyfrost/polyplus/mixin/client/access/MinecraftAccessor.java new file mode 100644 index 0000000..d42011f --- /dev/null +++ b/src/main/java/org/polyfrost/polyplus/mixin/client/access/MinecraftAccessor.java @@ -0,0 +1,12 @@ +package org.polyfrost.polyplus.mixin.client.access; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.User; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(Minecraft.class) +public interface MinecraftAccessor { + @Accessor("user") + void setUser(User user); +} diff --git a/src/main/java/org/polyfrost/polyplus/mixin/client/access/UserAccessor.java b/src/main/java/org/polyfrost/polyplus/mixin/client/access/UserAccessor.java new file mode 100644 index 0000000..88f98aa --- /dev/null +++ b/src/main/java/org/polyfrost/polyplus/mixin/client/access/UserAccessor.java @@ -0,0 +1,23 @@ +package org.polyfrost.polyplus.mixin.client.access; + +import net.minecraft.client.User; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Mutable; +import org.spongepowered.asm.mixin.gen.Accessor; + +import java.util.UUID; + +@Mixin(User.class) +public interface UserAccessor { + @Mutable + @Accessor("name") + void setName(String name); + + @Mutable + @Accessor("uuid") + void setUuid(UUID uuid); + + @Mutable + @Accessor("accessToken") + void setAccessToken(String accessToken); +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusMainMenuScreen.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusMainMenuScreen.kt index de80d18..b58e789 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusMainMenuScreen.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusMainMenuScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.CompositionLocalProvider @@ -45,6 +46,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment @@ -61,6 +63,7 @@ import androidx.compose.ui.graphics.LinearGradientShader import androidx.compose.ui.graphics.Shader import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.toComposeImageBitmap @@ -75,9 +78,16 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.platform.Font import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.window.PopupPositionProvider +import kotlin.math.roundToInt import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup @@ -87,6 +97,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import org.jetbrains.skia.Image as SkiaImage import org.polyfrost.oneconfig.internal.ui.components.Icon import org.polyfrost.oneconfig.internal.ui.components.LocalUiOversample @@ -94,6 +108,7 @@ import org.polyfrost.oneconfig.internal.ui.components.NotificationsCenter import org.polyfrost.oneconfig.internal.ui.compose.ComposeScreen import org.polyfrost.polyplus.client.PolyPlusConfig import org.polyfrost.polyplus.client.host.E4mcSupport +import org.polyfrost.polyplus.client.launcher.OneLauncherAccounts import org.polyfrost.polyplus.client.host.HostWorldManager import org.polyfrost.oneconfig.internal.ui.themes.Accent import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme @@ -283,61 +298,103 @@ private object MainMenuRasterAssets { }.getOrNull()?.also { cache[path] = it } } -private object MainMenuPlayerHead { - @Volatile - private var head: ImageBitmap? = null - @Volatile - private var requested = false - private const val SIZE = 64 - - fun get(): ImageBitmap? = head - - fun ensureLoaded() { - if (requested) return - requested = true - Thread({ runCatching { load() }.onFailure { requested = false } }, "polyplus-menu-head").apply { - isDaemon = true - start() +private object MenuHeadCache { + private val heads = ConcurrentHashMap() + private val requested: MutableSet = Collections.newSetFromMap(ConcurrentHashMap()) + var version by mutableStateOf(0) + private set + + fun get(id: java.util.UUID, name: String): ImageBitmap? { + version + heads[id]?.let { return it } + if (requested.add(id)) { + Thread({ + val face = runCatching { loadFaceByUuid(id) }.getOrNull() + if (face != null) heads[id] = face else requested.remove(id) + version++ // snapshot state is writable off-thread + }, "polyplus-account-head").apply { + isDaemon = true + start() + } } + return heads[id] } +} - private fun load() { - val mc = net.minecraft.client.Minecraft.getInstance() - val url = skinUrl(mc) ?: return - val skin = javax.imageio.ImageIO.read(java.net.URI(url).toURL()) ?: return - head = buildFace(skin) - } +private const val MENU_HEAD_SIZE = 64 + +private fun loadFaceByUuid(uuid: java.util.UUID): ImageBitmap? { + val url = mojangSkinUrl(uuid) ?: return defaultSkinFace(uuid) + val skin = javax.imageio.ImageIO.read(java.net.URI(url).toURL()) ?: return defaultSkinFace(uuid) + return buildFace(skin) +} - private fun skinUrl(mc: net.minecraft.client.Minecraft): String? { - val service = - //? if >= 1.21.10 { - mc.services().sessionService - //?} else { - /*mc.minecraftSessionService - *///?} - val texture = runCatching { service.getTextures(mc.gameProfile).skin() }.getOrNull() ?: return null - return texture.url +private fun defaultSkinFace(uuid: java.util.UUID): ImageBitmap? = runCatching { + val skinAsset = net.minecraft.client.resources.DefaultPlayerSkin.get(uuid) + //? if >= 1.21.10 { + val location = skinAsset.body().texturePath() + //?} else { + /*val location = skinAsset.texture() + *///?} + val manager = net.minecraft.client.Minecraft.getInstance().resourceManager + val resource = manager.getResource(location).orElse(null) ?: return null + val skin = resource.open().use { javax.imageio.ImageIO.read(it) } ?: return null + buildFace(skin) +}.getOrNull() + +private fun mojangSkinUrl(uuid: java.util.UUID): String? = runCatching { + val id = uuid.toString().replace("-", "") + val body = httpGetString("https://sessionserver.mojang.com/session/minecraft/profile/$id") + ?: return null + val root = org.polyfrost.polyplus.client.PolyPlusClient.JSON.parseToJsonElement(body).jsonObject + val props = root["properties"]?.jsonArray ?: return null + val texturesValue = props.firstOrNull { + it.jsonObject["name"]?.jsonPrimitive?.content == "textures" + }?.jsonObject?.get("value")?.jsonPrimitive?.content ?: return null + val decoded = String( + java.util.Base64.getDecoder().decode(texturesValue), + java.nio.charset.StandardCharsets.UTF_8, + ) + org.polyfrost.polyplus.client.PolyPlusClient.JSON.parseToJsonElement(decoded) + .jsonObject["textures"]?.jsonObject + ?.get("SKIN")?.jsonObject + ?.get("url")?.jsonPrimitive?.content +}.getOrNull() + +private fun httpGetString(url: String): String? { + val conn = java.net.URI(url).toURL().openConnection() as java.net.HttpURLConnection + return try { + conn.connectTimeout = 8000 + conn.readTimeout = 8000 + if (conn.responseCode == 200) { + conn.inputStream.bufferedReader(java.nio.charset.StandardCharsets.UTF_8).use { it.readText() } + } else { + null + } + } finally { + conn.disconnect() } +} - private fun buildFace(skin: java.awt.image.BufferedImage): ImageBitmap { - val out = ByteArray(SIZE * SIZE * 4) - for (y in 0 until SIZE) { - val oy = y * 8 / SIZE - for (x in 0 until SIZE) { - val ox = x * 8 / SIZE - val base = skin.getRGB(8 + ox, 8 + oy) - val hat = runCatching { skin.getRGB(40 + ox, 8 + oy) }.getOrDefault(0) - val argb = if ((hat ushr 24) != 0) hat else base - val i = (y * SIZE + x) * 4 - out[i] = argb.toByte() // B - out[i + 1] = (argb ushr 8).toByte() // G - out[i + 2] = (argb ushr 16).toByte() // R - out[i + 3] = 0xFF.toByte() // A (face is opaque) - } +private fun buildFace(skin: java.awt.image.BufferedImage): ImageBitmap { + val size = MENU_HEAD_SIZE + val out = ByteArray(size * size * 4) + for (y in 0 until size) { + val oy = y * 8 / size + for (x in 0 until size) { + val ox = x * 8 / size + val base = skin.getRGB(8 + ox, 8 + oy) + val hat = runCatching { skin.getRGB(40 + ox, 8 + oy) }.getOrDefault(0) + val argb = if ((hat ushr 24) != 0) hat else base + val i = (y * size + x) * 4 + out[i] = argb.toByte() // B + out[i + 1] = (argb ushr 8).toByte() // G + out[i + 2] = (argb ushr 16).toByte() // R + out[i + 3] = 0xFF.toByte() // A (face is opaque) } - return SkiaImage.makeRaster(org.jetbrains.skia.ImageInfo.makeN32Premul(SIZE, SIZE), out, SIZE * 4) - .toComposeImageBitmap() } + return SkiaImage.makeRaster(org.jetbrains.skia.ImageInfo.makeN32Premul(size, size), out, size * 4) + .toComposeImageBitmap() } private class MenuActions( @@ -383,6 +440,7 @@ private val Color.asSelectedBackground: Color get() = copy(alpha = 0.22f) private val Scrim = Color(0xB3000000) private val WarnColor = Color(0xFFF5A623) private val DangerColor = Color(0xFFFF5A5A) +private val SuccessColor = Color(0xFF4ADE80) private val TextPrimary: Color @Composable get() = LocalTheme.current.textColor private val TextSecondary: Color @@ -1089,34 +1147,411 @@ private fun ServerRow( @Composable private fun AccountPill(name: String, assetsReady: Boolean) { - var head by remember { mutableStateOf(MainMenuPlayerHead.get()) } - LaunchedEffect(Unit) { - MainMenuPlayerHead.ensureLoaded() - while (head == null) { - delay(150L) - head = MainMenuPlayerHead.get() ?: continue + val scope = rememberCoroutineScope() + var open by remember { mutableStateOf(false) } + var accounts by remember { mutableStateOf?>(null) } + var busy by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + var pillSize by remember { mutableStateOf(IntSize.Zero) } + var pillBounds by remember { mutableStateOf(androidx.compose.ui.geometry.Rect.Zero) } + + suspend fun reload() { + accounts = withContext(Dispatchers.IO) { OneLauncherAccounts.list() } + } + + LaunchedEffect(open) { + if (open) { + error = null + reload() + } + } + + val onSwitch: (OneLauncherAccounts.Account) -> Unit = { account -> + if (busy == null && !account.active) { + scope.launch { + busy = "Switching…" + error = null + val ok = withContext(Dispatchers.IO) { OneLauncherAccounts.switchTo(account.id) } + if (!ok) error = "Couldn't switch to that account" + reload() + busy = null + } } } + val onAddMicrosoft: () -> Unit = { + if (busy == null) { + scope.launch { + busy = "Finish signing in in your browser…" + error = null + runCatching { withContext(Dispatchers.IO) { OneLauncherAccounts.addMicrosoft() } } + .onFailure { error = it.message ?: "Microsoft sign-in failed" } + reload() + busy = null + } + } + } + val onAddOffline: (String) -> Unit = { username -> + if (busy == null) { + scope.launch { + busy = "Adding account…" + error = null + runCatching { withContext(Dispatchers.IO) { OneLauncherAccounts.addOffline(username) } } + .onFailure { error = it.message ?: "Couldn't add that account" } + reload() + busy = null + } + } + } + val onRemove: (OneLauncherAccounts.Account) -> Unit = { account -> + if (busy == null) { + scope.launch { + busy = "Removing account…" + error = null + val ok = withContext(Dispatchers.IO) { OneLauncherAccounts.remove(account.id) } + if (!ok) error = "Couldn't remove that account" + reload() + busy = null + } + } + } + + val activeName = accounts?.firstOrNull { it.active }?.username ?: name + val chevronRotation = if (open) 0f else 180f + val localId = runCatching { net.minecraft.client.Minecraft.getInstance().user.profileId }.getOrNull() + val head = localId?.let { MenuHeadCache.get(it, activeName) } + Box( modifier = Modifier .fillMaxWidth() - .height(45.dp) + .onSizeChanged { pillSize = it } + .onGloballyPositioned { pillBounds = it.boundsInWindow() }, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(45.dp) + .clip(PanelShape) + .background(PanelBackground) + .border(BorderWidth, PanelBorderBrush, PanelShape) + .clickableWithSound { open = !open } + .padding(horizontal = 10.dp), + contentAlignment = Alignment.Center, + ) { + val avatarModifier = Modifier.align(Alignment.CenterStart).size(28.dp).clip(ppShape(3.dp)) + val currentHead = head + if (currentHead != null) { + Image(currentHead, contentDescription = null, modifier = avatarModifier, contentScale = ContentScale.Crop) + } else { + RasterImage(ASSETS + "avatar.png", avatarModifier, assetsReady = assetsReady, contentScale = ContentScale.Crop) + } + MenuText(activeName, fontSize = 16.sp) + MenuIcon( + ASSETS + "chevron-up.svg", + TextPrimary, + Modifier.align(Alignment.CenterEnd).size(16.dp).rotate(chevronRotation), + assetsReady, + ) + } + if (open) { + val totalScale = if (pillSize.width > 0) pillBounds.width / pillSize.width else 1f + val positionProvider = remember(pillBounds, totalScale) { + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + val gap = (8f * totalScale).roundToInt() + return IntOffset( + (pillBounds.right - popupContentSize.width).roundToInt(), + (pillBounds.bottom + gap).roundToInt(), + ) + } + } + } + Popup( + popupPositionProvider = positionProvider, + onDismissRequest = { if (busy == null) open = false }, + properties = PopupProperties(focusable = true, clippingEnabled = false), + ) { + AccountSwitcherPanel( + panelWidth = with(LocalDensity.current) { pillSize.width.toDp() }, + scale = totalScale, + accounts = accounts, + assetsReady = assetsReady, + busy = busy, + error = error, + onSwitch = onSwitch, + onRemove = onRemove, + onAddMicrosoft = onAddMicrosoft, + onAddOffline = onAddOffline, + ) + } + } + } +} + +@Composable +private fun AccountSwitcherPanel( + panelWidth: Dp, + scale: Float, + accounts: List?, + assetsReady: Boolean, + busy: String?, + error: String?, + onSwitch: (OneLauncherAccounts.Account) -> Unit, + onRemove: (OneLauncherAccounts.Account) -> Unit, + onAddMicrosoft: () -> Unit, + onAddOffline: (String) -> Unit, +) { + var offlineEntry by remember { mutableStateOf(false) } + val idle = busy == null + + Column( + modifier = Modifier + .width(panelWidth) + .graphicsLayer { + scaleX = scale + scaleY = scale + transformOrigin = TransformOrigin(1f, 0f) + } .clip(PanelShape) - .background(PanelBackground) + .background(PageBackground.copy(alpha = 0.96f)) .border(BorderWidth, PanelBorderBrush, PanelShape) - .clickable {} - .padding(horizontal = 10.dp), - contentAlignment = Alignment.Center, + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + MenuText( + "Accounts", + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.align(Alignment.Start), + ) + Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(PanelBorderBrush)) + + when { + accounts == null -> MenuText( + "Loading…", + fontSize = 13.sp, + color = TextSecondary, + modifier = Modifier.align(Alignment.Start), + ) + accounts.isEmpty() -> MenuText( + "No accounts found in the launcher", + fontSize = 13.sp, + color = TextSecondary, + modifier = Modifier.align(Alignment.Start), + ) + else -> Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 300.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + accounts.forEach { account -> + AccountRow( + account = account, + assetsReady = assetsReady, + enabled = idle, + onClick = { onSwitch(account) }, + onRemove = { onRemove(account) }, + ) + } + } + } + + Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(PanelBorderBrush)) + + if (offlineEntry) { + OfflineAccountEntry( + enabled = idle, + onSubmit = { username -> + offlineEntry = false + onAddOffline(username) + }, + onCancel = { offlineEntry = false }, + ) + } else { + AddAccountButton( + icon = ASSETS + "log-in-04.svg", + text = "Add Microsoft account", + enabled = idle, + assetsReady = assetsReady, + onClick = onAddMicrosoft, + ) + AddAccountButton( + icon = ASSETS + "user-01.svg", + text = "Add offline account", + enabled = idle, + assetsReady = assetsReady, + onClick = { offlineEntry = true }, + ) + } + + if (busy != null) { + MenuText(busy, fontSize = 12.sp, color = TextSecondary, modifier = Modifier.align(Alignment.Start)) + } + if (error != null) { + MenuText(error, fontSize = 12.sp, color = DangerColor, modifier = Modifier.align(Alignment.Start)) + } + } +} + +@Composable +private fun AccountRow( + account: OneLauncherAccounts.Account, + assetsReady: Boolean, + enabled: Boolean, + onClick: () -> Unit, + onRemove: () -> Unit, +) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + var confirmRemove by remember { mutableStateOf(false) } + val head = MenuHeadCache.get(account.id, account.username) + val clickable = enabled && !account.active && !confirmRemove + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(ppShape(8.dp)) + .background(if (hovered && clickable) LocalTheme.current.componentBackground.copy(alpha = 0.4f) else Color.Transparent) + .hoverable(interaction) + .then(if (clickable) Modifier.clickableWithSound(onClick) else Modifier) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - val avatarModifier = Modifier.align(Alignment.CenterStart).size(28.dp).clip(ppShape(3.dp)) - val currentHead = head - if (currentHead != null) { - Image(currentHead, contentDescription = null, modifier = avatarModifier, contentScale = ContentScale.Crop) + val avatarModifier = Modifier.size(30.dp).clip(ppShape(4.dp)) + if (head != null) { + Image(head, contentDescription = null, modifier = avatarModifier, contentScale = ContentScale.Crop) } else { RasterImage(ASSETS + "avatar.png", avatarModifier, assetsReady = assetsReady, contentScale = ContentScale.Crop) } - MenuText(name, fontSize = 16.sp) - MenuIcon(ASSETS + "chevron-up.svg", TextPrimary, Modifier.align(Alignment.CenterEnd).size(16.dp).rotate(180f), assetsReady) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + MenuText( + account.username, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.align(Alignment.Start), + ) + val subtitle = when { + confirmRemove -> "Remove this account?" + account.active -> "Active" + account.microsoft -> "Microsoft" + else -> "Offline" + } + MenuText( + subtitle, + fontSize = 11.sp, + color = if (confirmRemove) DangerColor else TextSecondary, + modifier = Modifier.align(Alignment.Start), + ) + } + if (confirmRemove) { + AccountActionIcon(ASSETS + "check-circle.svg", DangerColor, enabled) { + confirmRemove = false + onRemove() + } + AccountActionIcon(ASSETS + "x-close.svg", TextSecondary, enabled) { confirmRemove = false } + } else { + if (account.active) { + MenuIcon(ASSETS + "check-circle.svg", SuccessColor, Modifier.size(18.dp), assetsReady) + } + if (hovered) { + AccountActionIcon(ASSETS + "trash-01.svg", TextSecondary, enabled) { confirmRemove = true } + } + } + } +} + +@Composable +private fun AddAccountButton( + icon: String, + text: String, + enabled: Boolean, + assetsReady: Boolean, + onClick: () -> Unit, +) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + Row( + modifier = Modifier + .fillMaxWidth() + .clip(ppShape(8.dp)) + .background(if (hovered && enabled) LocalTheme.current.componentBackground.copy(alpha = 0.4f) else Color.Transparent) + .hoverable(interaction) + .then(if (enabled) Modifier.clickableWithSound(onClick) else Modifier) + .alpha(if (enabled) 1f else 0.5f) + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + MenuIcon(icon, TextPrimary, Modifier.size(18.dp), assetsReady) + MenuText(text, fontSize = 13.sp, modifier = Modifier.align(Alignment.CenterVertically)) + } +} + +@Composable +private fun OfflineAccountEntry( + enabled: Boolean, + onSubmit: (String) -> Unit, + onCancel: () -> Unit, +) { + var value by remember { mutableStateOf("") } + val bodyFont = LocalTheme.current.typography.family + val valid = value.trim().length in 3..16 + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = Modifier + .weight(1f) + .height(34.dp) + .clip(ppShape(6.dp)) + .background(LocalTheme.current.componentBackground.copy(alpha = 0.5f)) + .border(BorderWidth, PanelBorderBrush, ppShape(6.dp)) + .padding(horizontal = 10.dp), + contentAlignment = Alignment.CenterStart, + ) { + BasicTextField( + value = value, + onValueChange = { if (it.length <= 16) value = it }, + singleLine = true, + enabled = enabled, + textStyle = TextStyle(color = TextPrimary, fontSize = 13.sp, fontFamily = bodyFont), + cursorBrush = SolidColor(Accent), + decorationBox = { inner -> + if (value.isEmpty()) { + MenuText("Username", fontSize = 13.sp, color = TextSecondary) + } + inner() + }, + ) + } + AccountActionIcon(ASSETS + "check-circle.svg", SuccessColor, enabled && valid) { + if (valid) onSubmit(value.trim()) + } + AccountActionIcon(ASSETS + "x-close.svg", TextSecondary, enabled, onCancel) + } +} + +@Composable +private fun AccountActionIcon(icon: String, color: Color, enabled: Boolean, onClick: () -> Unit) { + Box( + modifier = Modifier + .size(30.dp) + .clip(ppShape(6.dp)) + .alpha(if (enabled) 1f else 0.4f) + .then(if (enabled) Modifier.clickableWithSound(onClick) else Modifier), + contentAlignment = Alignment.Center, + ) { + MenuIcon(icon, color, Modifier.size(18.dp), assetsReady = true) } } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/launcher/AccountSwitch.kt b/src/main/kotlin/org/polyfrost/polyplus/client/launcher/AccountSwitch.kt new file mode 100644 index 0000000..35bbad2 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/launcher/AccountSwitch.kt @@ -0,0 +1,33 @@ +package org.polyfrost.polyplus.client.launcher + +import net.minecraft.client.Minecraft +import net.minecraft.client.User +import org.apache.logging.log4j.LogManager +import org.polyfrost.polyplus.client.utils.ClientPlatform +import org.polyfrost.polyplus.mixin.client.access.MinecraftAccessor +import org.polyfrost.polyplus.mixin.client.access.UserAccessor + +object AccountSwitch { + private val LOGGER = LogManager.getLogger("PolyPlus/Accounts") + + fun apply(account: LauncherAccountStore.StoredAccount): Boolean = runCatching { + val newId = LauncherAccountStore.parseUuid(account.id) ?: error("bad account id ${account.id}") + ClientPlatform.runOnMainSync { + val mc = Minecraft.getInstance() + val user = mc.user + mutateUser(user, account.username, newId, account.accessToken) + (mc as MinecraftAccessor).setUser(user) + } + true + }.getOrElse { + LOGGER.error("Failed to switch account in-session", it) + false + } + + private fun mutateUser(user: User, name: String, uuid: java.util.UUID, accessToken: String) { + val accessor = user as UserAccessor + accessor.setName(name) + accessor.setUuid(uuid) + accessor.setAccessToken(accessToken) + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/launcher/LauncherAccountStore.kt b/src/main/kotlin/org/polyfrost/polyplus/client/launcher/LauncherAccountStore.kt new file mode 100644 index 0000000..fa2127b --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/launcher/LauncherAccountStore.kt @@ -0,0 +1,121 @@ +package org.polyfrost.polyplus.client.launcher + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import net.minecraft.client.Minecraft +import org.apache.logging.log4j.LogManager +import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.polyplus.client.utils.ClientPlatform +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.UUID + +object LauncherAccountStore { + private val LOGGER = LogManager.getLogger("PolyPlus/Accounts") + private const val AUTH_FILE = "auth.json" + private const val MAX_WALK_UP = 5 + + private val WRITE_JSON = Json { + prettyPrint = true + encodeDefaults = true + } + + @Serializable + data class StoredAccount( + val id: String, + val username: String, + @SerialName("access_token") val accessToken: String = "", + @SerialName("refresh_token") val refreshToken: String = "", + val expires: String = "", + val kind: String = "microsoft", + ) + + @Serializable + data class CredentialsStore( + val users: Map = emptyMap(), + @SerialName("default_user") val defaultUser: String? = null, + ) + + fun load(): CredentialsStore { + val file = authFile() ?: return CredentialsStore() + return runCatching { + PolyPlusClient.JSON.decodeFromString(CredentialsStore.serializer(), file.readText()) + }.getOrElse { + LOGGER.warn("Failed to read launcher auth file at {}", file, it) + CredentialsStore() + } + } + + fun save(store: CredentialsStore) { + val file = authFileForWrite() ?: run { + LOGGER.warn("No writable launcher directory found; cannot save accounts") + return + } + runCatching { + file.parentFile?.mkdirs() + val tmp = File(file.parentFile, "$AUTH_FILE.tmp") + tmp.writeText(WRITE_JSON.encodeToString(CredentialsStore.serializer(), store)) + runCatching { + Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE) + }.getOrElse { + Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + }.onFailure { LOGGER.warn("Failed to write launcher auth file at {}", file, it) } + } + + fun hasMicrosoftAccount(store: CredentialsStore): Boolean = + store.users.values.any { it.kind.equals("microsoft", ignoreCase = true) } + + fun parseUuid(value: String): UUID? = runCatching { UUID.fromString(value) }.getOrNull() + + private fun authFile(): File? { + walkUpForAuth()?.let { return it } + for (dir in platformLauncherDirs()) { + val candidate = File(dir, AUTH_FILE) + if (candidate.isFile) return candidate + } + return null + } + + private fun authFileForWrite(): File? = + authFile() ?: platformLauncherDirs().firstOrNull()?.let { File(it, AUTH_FILE) } + + private fun walkUpForAuth(): File? { + var dir: File? = Minecraft.getInstance().gameDirectory.absoluteFile + var depth = 0 + while (dir != null && depth <= MAX_WALK_UP) { + val candidate = File(dir, AUTH_FILE) + if (candidate.isFile) return candidate + dir = dir.parentFile + depth++ + } + return null + } + + private fun platformLauncherDirs(): List { + val home = File(System.getProperty("user.home") ?: return emptyList()) + return when { + ClientPlatform.isWindows -> { + val base = System.getenv("LOCALAPPDATA")?.let(::File) ?: File(home, "AppData/Local") + listOf( + File(base, "Polyfrost/OneClient/data"), + File(base, "Polyfrost/OneClient-dev/data"), + ) + } + ClientPlatform.isMac -> { + val base = File(home, "Library/Application Support") + listOf( + File(base, "org.Polyfrost.OneClient"), + File(base, "org.Polyfrost.OneClient-dev"), + ) + } + else -> { + val base = System.getenv("XDG_DATA_HOME")?.takeIf { it.isNotBlank() }?.let(::File) + ?: File(home, ".local/share") + listOf(File(base, "oneclient"), File(base, "oneclient-dev")) + } + } + } +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/launcher/MicrosoftAuth.kt b/src/main/kotlin/org/polyfrost/polyplus/client/launcher/MicrosoftAuth.kt new file mode 100644 index 0000000..c6a2a5a --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/launcher/MicrosoftAuth.kt @@ -0,0 +1,348 @@ +package org.polyfrost.polyplus.client.launcher + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import io.ktor.client.call.body +import io.ktor.client.request.accept +import io.ktor.client.request.forms.FormDataContent +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.Parameters +import io.ktor.http.contentType +import io.ktor.http.formUrlEncode +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import org.apache.logging.log4j.LogManager +import org.polyfrost.polyplus.client.PolyPlusClient +import org.polyfrost.polyplus.client.utils.ClientPlatform +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.security.SecureRandom +import java.time.Instant +import java.util.Base64 +import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds + +object MicrosoftAuth { + private val LOGGER = LogManager.getLogger("PolyPlus/Accounts") + + private const val CLIENT_ID = "9419b7ee-1448-4d1b-b52a-550d8f36ab56" + private const val SCOPES = "XboxLive.SignIn XboxLive.offline_access" + private const val AUTHORIZE_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize" + private const val TOKEN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token" + + private const val LOGIN_TIMEOUT_MS = 5 * 60 * 1000L + + private val HTTP get() = PolyPlusClient.HTTP + private val B64URL = Base64.getUrlEncoder().withoutPadding() + + suspend fun login(): LauncherAccountStore.StoredAccount { + val verifier = randomToken() + val challenge = pkceChallenge(verifier) + val csrfState = randomToken() + + val server = HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0) + val redirectUri = "http://127.0.0.1:${server.address.port}" + val codeResult = CompletableDeferred() + + server.createContext("/") { exchange -> handleRedirect(exchange, csrfState, codeResult) } + server.executor = null + server.start() + + try { + ClientPlatform.openUri(authorizeUrl(redirectUri, csrfState, challenge)) + val code = withTimeout(LOGIN_TIMEOUT_MS.milliseconds) { codeResult.await() } + val msa = exchangeAuthCode(code, redirectUri, verifier) + return accountFromMsa(msa) + } finally { + server.stop(0) + } + } + + private fun authorizeUrl(redirectUri: String, state: String, challenge: String): String { + val params = Parameters.build { + append("client_id", CLIENT_ID) + append("response_type", "code") + append("redirect_uri", redirectUri) + append("response_mode", "query") + append("scope", SCOPES) + append("state", state) + append("code_challenge", challenge) + append("code_challenge_method", "S256") + append("prompt", "select_account") + } + return "$AUTHORIZE_URL?${params.formUrlEncode()}" + } + + private fun handleRedirect( + exchange: HttpExchange, + expectedState: String, + result: CompletableDeferred, + ) { + val query = exchange.requestURI.rawQuery?.let(::parseQuery).orEmpty() + val page: RedirectPage = when { + query["error"] != null -> { + result.completeExceptionally(IllegalStateException("Microsoft sign-in failed: ${query["error"]}")) + RedirectPage.FAILED + } + query["state"] != null && query["state"] != expectedState -> RedirectPage.FAILED + query["code"] != null -> { + result.complete(query.getValue("code")) + RedirectPage.SUCCESS + } + else -> RedirectPage.WAITING + } + respond(exchange, page) + } + + private suspend fun exchangeAuthCode( + code: String, + redirectUri: String, + verifier: String, + ): MsaToken { + val response: MsaTokenResponse = HTTP.post(TOKEN_URL) { + accept(ContentType.Application.Json) + setBody( + FormDataContent( + Parameters.build { + append("client_id", CLIENT_ID) + append("grant_type", "authorization_code") + append("code", code) + append("redirect_uri", redirectUri) + append("code_verifier", verifier) + append("scope", SCOPES) + }, + ), + ) + }.body() + return MsaToken(response.accessToken, response.refreshToken, response.expiresIn, Instant.now()) + } + + private suspend fun accountFromMsa(msa: MsaToken): LauncherAccountStore.StoredAccount { + val xblToken = xblAuthenticate("d=${msa.accessToken}") + val xsts = xstsAuthorize(xblToken) + val xstsToken = xsts.token ?: error("XSTS returned no token") + val uhs = xsts.displayClaims?.xui?.firstOrNull()?.uhs ?: error("XSTS returned no user hash") + + val mcToken = minecraftLogin(uhs, xstsToken) + minecraftEntitlements(mcToken) + val profile = minecraftProfile(mcToken) + + return LauncherAccountStore.StoredAccount( + id = normalizeUuid(profile.id).toString(), + username = profile.name, + accessToken = mcToken, + refreshToken = msa.refreshToken, + expires = msa.obtainedAt.plusSeconds(msa.expiresIn).toString(), + kind = "microsoft", + ) + } + + private suspend fun xblAuthenticate(rpsTicket: String): String { + val body = buildJsonObject { + putJsonObject("Properties") { + put("AuthMethod", "RPS") + put("SiteName", "user.auth.xboxlive.com") + put("RpsTicket", rpsTicket) + } + put("RelyingParty", "http://auth.xboxlive.com") + put("TokenType", "JWT") + } + val res: XboxTokenResponse = HTTP.post("https://user.auth.xboxlive.com/user/authenticate") { + accept(ContentType.Application.Json) + header("x-xbl-contract-version", "1") + contentType(ContentType.Application.Json) + setBody(body) + }.body() + return res.token ?: error("Xbox Live returned no token") + } + + private suspend fun xstsAuthorize(userToken: String): XboxTokenResponse { + val body = buildJsonObject { + putJsonObject("Properties") { + put("SandboxId", "RETAIL") + putJsonArray("UserTokens") { add(userToken) } + } + put("RelyingParty", "rp://api.minecraftservices.com/") + put("TokenType", "JWT") + } + return HTTP.post("https://xsts.auth.xboxlive.com/xsts/authorize") { + accept(ContentType.Application.Json) + header("x-xbl-contract-version", "1") + contentType(ContentType.Application.Json) + setBody(body) + }.body() + } + + private suspend fun minecraftLogin(uhs: String, xstsToken: String): String { + val body = buildJsonObject { put("identityToken", "XBL3.0 x=$uhs;$xstsToken") } + val res: MinecraftTokenResponse = HTTP.post("https://api.minecraftservices.com/authentication/login_with_xbox") { + accept(ContentType.Application.Json) + contentType(ContentType.Application.Json) + setBody(body) + }.body() + return res.accessToken + } + + private suspend fun minecraftEntitlements(token: String) { + runCatching { + HTTP.get("https://api.minecraftservices.com/entitlements/mcstore") { + accept(ContentType.Application.Json) + header(HttpHeaders.Authorization, "Bearer $token") + } + }.onFailure { LOGGER.debug("entitlements check failed", it) } + } + + private suspend fun minecraftProfile(token: String): MinecraftProfile = + HTTP.get("https://api.minecraftservices.com/minecraft/profile") { + accept(ContentType.Application.Json) + header(HttpHeaders.Authorization, "Bearer $token") + }.body() + + private fun randomToken(): String { + val bytes = ByteArray(32) + SecureRandom().nextBytes(bytes) + return B64URL.encodeToString(bytes) + } + + private fun pkceChallenge(verifier: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(StandardCharsets.US_ASCII)) + return B64URL.encodeToString(digest) + } + + private fun normalizeUuid(id: String): UUID = runCatching { UUID.fromString(id) }.getOrElse { + val hex = id.replace("-", "") + UUID.fromString( + "${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-" + + "${hex.substring(16, 20)}-${hex.substring(20)}", + ) + } + + private fun parseQuery(raw: String): Map = raw.split("&").mapNotNull { pair -> + val idx = pair.indexOf('=') + if (idx <= 0) return@mapNotNull null + val key = URLDecoder.decode(pair.substring(0, idx), StandardCharsets.UTF_8) + val value = URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8) + key to value + }.toMap() + + private enum class RedirectPage(val accent: String, val glyph: String, val heading: String, val detail: String) { + WAITING( + "#2567d8", + """
""", + "Signing you in", + "Finish signing in with Microsoft in this window.", + ), + SUCCESS( + "#45de2b", + """""", + "You're signed in!", + "You can close this tab and return to OneClient.", + ), + FAILED( + "#ff0000", + """""", + "Sign-in failed", + "Something went wrong. Close this tab and try again from OneClient.", + ), + } + + private fun respond(exchange: HttpExchange, page: RedirectPage) { + val body = """ + + + + OneClient + + +
+
${page.glyph}
+

${page.heading}

+

${page.detail}

+
+ + """.trimIndent().toByteArray(StandardCharsets.UTF_8) + runCatching { + exchange.responseHeaders.add("Content-Type", "text/html; charset=utf-8") + exchange.sendResponseHeaders(200, body.size.toLong()) + exchange.responseBody.use { it.write(body) } + } + } + + private class MsaToken( + val accessToken: String, + val refreshToken: String, + val expiresIn: Long, + val obtainedAt: Instant, + ) + + @Serializable + private data class MsaTokenResponse( + @SerialName("access_token") val accessToken: String, + @SerialName("refresh_token") val refreshToken: String = "", + @SerialName("expires_in") val expiresIn: Long = 3600, + ) + + @Serializable + private data class XboxTokenResponse( + @SerialName("Token") val token: String? = null, + @SerialName("DisplayClaims") val displayClaims: DisplayClaims? = null, + ) + + @Serializable + private data class DisplayClaims(val xui: List = emptyList()) + + @Serializable + private data class Xui(val uhs: String) + + @Serializable + private data class MinecraftTokenResponse(@SerialName("access_token") val accessToken: String) + + @Serializable + private data class MinecraftProfile(val id: String, val name: String) +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/launcher/OneLauncherAccounts.kt b/src/main/kotlin/org/polyfrost/polyplus/client/launcher/OneLauncherAccounts.kt new file mode 100644 index 0000000..6334440 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/launcher/OneLauncherAccounts.kt @@ -0,0 +1,102 @@ +package org.polyfrost.polyplus.client.launcher + +import org.apache.logging.log4j.LogManager +import java.util.UUID + +object OneLauncherAccounts { + private val LOGGER = LogManager.getLogger("PolyPlus/Accounts") + + data class Account( + val id: UUID, + val username: String, + val microsoft: Boolean, + val active: Boolean, + ) + + fun list(): List { + val store = LauncherAccountStore.load() + val defaultId = store.defaultUser?.let { LauncherAccountStore.parseUuid(it) } + ?: store.users.keys.firstNotNullOfOrNull { LauncherAccountStore.parseUuid(it) } + + return store.users.values.mapNotNull { stored -> + val id = LauncherAccountStore.parseUuid(stored.id) ?: return@mapNotNull null + Account( + id = id, + username = stored.username, + microsoft = stored.kind.equals("microsoft", ignoreCase = true), + active = id == defaultId, + ) + }.sortedWith(compareByDescending { it.active }.thenBy { it.username.lowercase() }) + } + + fun switchTo(id: UUID): Boolean { + val store = LauncherAccountStore.load() + val stored = store.users[id.toString()] + ?: store.users.values.firstOrNull { LauncherAccountStore.parseUuid(it.id) == id } + ?: run { + LOGGER.warn("Cannot switch: account {} not in store", id) + return false + } + if (!AccountSwitch.apply(stored)) return false + LauncherAccountStore.save(store.copy(defaultUser = stored.id)) + return true + } + + fun remove(id: UUID): Boolean { + val store = LauncherAccountStore.load() + val key = store.users.keys.firstOrNull { LauncherAccountStore.parseUuid(it) == id } ?: return false + val users = store.users.toMutableMap().apply { remove(key) } + val default = if (store.defaultUser == key) users.keys.firstOrNull() else store.defaultUser + LauncherAccountStore.save(store.copy(users = users, defaultUser = default)) + return true + } + + suspend fun addMicrosoft(): Account { + val account = MicrosoftAuth.login() + commit(account, makeDefaultIfNone = true) + return account.toAccount(active = false) + } + + fun addOffline(rawUsername: String): Account { + val username = rawUsername.trim() + val store = LauncherAccountStore.load() + + require(LauncherAccountStore.hasMicrosoftAccount(store)) { + "Add a Microsoft account before creating offline accounts" + } + require(username.length in 3..16) { "Username must be 3-16 characters" } + require(username.all { it.isLetterOrDigit() && it.code < 128 || it == '_' }) { + "Username may only contain letters, digits, and underscores" + } + require(store.users.values.none { it.username.equals(username, ignoreCase = true) }) { + "An account named $username already exists" + } + + val account = LauncherAccountStore.StoredAccount( + id = offlineUuid(username).toString(), + username = username, + expires = java.time.Instant.now().plus(java.time.Duration.ofDays(3650)).toString(), + kind = "offline", + ) + + commit(account, makeDefaultIfNone = false) + return account.toAccount(active = false) + } + + private fun commit(account: LauncherAccountStore.StoredAccount, makeDefaultIfNone: Boolean) { + val store = LauncherAccountStore.load() + val users = store.users.toMutableMap().apply { put(account.id, account) } + val default = if (makeDefaultIfNone && store.defaultUser == null) account.id else store.defaultUser + LauncherAccountStore.save(store.copy(users = users, defaultUser = default)) + } + + private fun offlineUuid(username: String): UUID = + UUID.nameUUIDFromBytes("OfflinePlayer:$username".toByteArray(Charsets.UTF_8)) + + private fun LauncherAccountStore.StoredAccount.toAccount(active: Boolean) = Account( + id = LauncherAccountStore.parseUuid(id) ?: UUID(0L, 0L), + username = username, + microsoft = kind.equals("microsoft", ignoreCase = true), + active = active, + ) +} diff --git a/src/main/resources/assets/polyplus/mainmenu/check-circle.svg b/src/main/resources/assets/polyplus/mainmenu/check-circle.svg new file mode 100644 index 0000000..370486f --- /dev/null +++ b/src/main/resources/assets/polyplus/mainmenu/check-circle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/assets/polyplus/mainmenu/trash-01.svg b/src/main/resources/assets/polyplus/mainmenu/trash-01.svg new file mode 100644 index 0000000..1386596 --- /dev/null +++ b/src/main/resources/assets/polyplus/mainmenu/trash-01.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/mixins.polyplus.json b/src/main/resources/mixins.polyplus.json index 114db29..195f599 100644 --- a/src/main/resources/mixins.polyplus.json +++ b/src/main/resources/mixins.polyplus.json @@ -9,70 +9,33 @@ "client": [ "Mixin_NoAI", "Mixin_SentryCrashReport", - "client.Mixin_ReplaceCapeTexture", - "client.Mixin_OneConfigNavigationGraph", - "client.Mixin_OneConfigNavigationGroups", - "client.Mixin_OneConfigUIScreenRoute", - "client.Mixin_ThemeRegistryBranding", - "client.NameTagBadgeMixin", "client.ChatEmojiMixin", "client.ChatScreenEmojiMixin", "client.ChatSendEmojiMixin", - "client.Mixin_ReplaceMainMenuEarly", - "client.Mixin_ReplaceMainMenu", - "client.Mixin_TrackRecentServers", + "client.Mixin_HostWorldButtonLabel", "client.Mixin_MainMenuFpsLimit", + "client.Mixin_OneConfigNavigationGraph", + "client.Mixin_OneConfigNavigationGroups", + "client.Mixin_OneConfigUIScreenRoute", "client.Mixin_PanoramaBlurRadius", - "client.Mixin_HostWorldButtonLabel" - //? if >= 1.21.8 && < 26.1 { - /*, - "client.PlayerTabBadgeMixin" - *///?} - //? if >= 1.21.1 && < 1.21.8 { - /*, - "client.PlayerTabBadgeLegacyMixin" - *///?} - //? if >= 26.1 { - , - "client.PlayerTabBadgeModernMixin" - //?} - //? if < 1.21.5 || >= 1.21.8 && < 26.1 { - /*, - "client.Mixin_PreviewComposeOverlay" - *///?} - //? if < 1.21.5 { - /*, - "client.MinecraftMainRenderTargetAccessor" - *///?} - //? if >= 26.1 && < 26.2 { - , - "client.Mixin_PreviewPresent" - //?} - //? if >= 26.2 { - /*, - "client.Mixin_PreviewPresent26_2" - *///?} - //? if >= 1.21.1 { - , + "client.Mixin_PreviewPresent", + "client.Mixin_ReplaceCapeTexture", + "client.Mixin_ReplaceMainMenu", + "client.Mixin_ReplaceMainMenuEarly", + "client.Mixin_ThemeRegistryBranding", + "client.Mixin_TrackRecentServers", + "client.NameTagBadgeMixin", + "client.PlayerTabBadgeModernMixin", + "client.access.MinecraftAccessor", + "client.access.UserAccessor", "client.cosmetics.AbstractClientPlayerMixin", "client.cosmetics.AvatarRendererInitMixin", - "client.cosmetics.LivingEntityRendererInvoker", - "client.cosmetics.PlayerModelMixin" - //?} - //? if >= 1.21.4 { - , + "client.cosmetics.AvatarRendererMixin", "client.cosmetics.AvatarRenderStateMixin", - "client.cosmetics.AvatarRendererMixin" - //?} - //? if >= 1.21.10 { - , + "client.cosmetics.LivingEntityRendererInvoker", + "client.cosmetics.Mixin_WaveyCapesPreviewCape", "client.cosmetics.Mixin_WaveyCapesPreviewSkip", - "client.cosmetics.Mixin_WaveyCapesPreviewCape" - //?} - //? if >= 1.21.4 && < 1.21.5 { - /*, - "client.cosmetics.EntityRenderDispatcherPlayerAccessor" - *///?} + "client.cosmetics.PlayerModelMixin" ], "verbose": true } From 6879fe6f799a085b89092ce450da1e1cebbb21e0 Mon Sep 17 00:00:00 2001 From: awruff Date: Tue, 21 Jul 2026 03:30:08 -0400 Subject: [PATCH 2/4] fix: mixins json mcdev plugin nuked the stonecutter :wilted_rose: --- src/main/resources/mixins.polyplus.json | 73 +++++++++++++++++++------ 1 file changed, 56 insertions(+), 17 deletions(-) diff --git a/src/main/resources/mixins.polyplus.json b/src/main/resources/mixins.polyplus.json index 195f599..c1af5e0 100644 --- a/src/main/resources/mixins.polyplus.json +++ b/src/main/resources/mixins.polyplus.json @@ -9,33 +9,72 @@ "client": [ "Mixin_NoAI", "Mixin_SentryCrashReport", - "client.ChatEmojiMixin", - "client.ChatScreenEmojiMixin", - "client.ChatSendEmojiMixin", - "client.Mixin_HostWorldButtonLabel", - "client.Mixin_MainMenuFpsLimit", + "client.Mixin_ReplaceCapeTexture", "client.Mixin_OneConfigNavigationGraph", "client.Mixin_OneConfigNavigationGroups", "client.Mixin_OneConfigUIScreenRoute", - "client.Mixin_PanoramaBlurRadius", - "client.Mixin_PreviewPresent", - "client.Mixin_ReplaceCapeTexture", - "client.Mixin_ReplaceMainMenu", - "client.Mixin_ReplaceMainMenuEarly", "client.Mixin_ThemeRegistryBranding", - "client.Mixin_TrackRecentServers", "client.NameTagBadgeMixin", - "client.PlayerTabBadgeModernMixin", + "client.ChatEmojiMixin", + "client.ChatScreenEmojiMixin", + "client.ChatSendEmojiMixin", + "client.Mixin_ReplaceMainMenuEarly", + "client.Mixin_ReplaceMainMenu", + "client.Mixin_TrackRecentServers", + "client.Mixin_MainMenuFpsLimit", + "client.Mixin_PanoramaBlurRadius", + "client.Mixin_HostWorldButtonLabel", "client.access.MinecraftAccessor", - "client.access.UserAccessor", + "client.access.UserAccessor" + //? if >= 1.21.8 && < 26.1 { + /*, + "client.PlayerTabBadgeMixin" + *///?} + //? if >= 1.21.1 && < 1.21.8 { + /*, + "client.PlayerTabBadgeLegacyMixin" + *///?} + //? if >= 26.1 { + , + "client.PlayerTabBadgeModernMixin" + //?} + //? if < 1.21.5 || >= 1.21.8 && < 26.1 { + /*, + "client.Mixin_PreviewComposeOverlay" + *///?} + //? if < 1.21.5 { + /*, + "client.MinecraftMainRenderTargetAccessor" + *///?} + //? if >= 26.1 && < 26.2 { + , + "client.Mixin_PreviewPresent" + //?} + //? if >= 26.2 { + /*, + "client.Mixin_PreviewPresent26_2" + *///?} + //? if >= 1.21.1 { + , "client.cosmetics.AbstractClientPlayerMixin", "client.cosmetics.AvatarRendererInitMixin", - "client.cosmetics.AvatarRendererMixin", - "client.cosmetics.AvatarRenderStateMixin", "client.cosmetics.LivingEntityRendererInvoker", - "client.cosmetics.Mixin_WaveyCapesPreviewCape", - "client.cosmetics.Mixin_WaveyCapesPreviewSkip", "client.cosmetics.PlayerModelMixin" + //?} + //? if >= 1.21.4 { + , + "client.cosmetics.AvatarRenderStateMixin", + "client.cosmetics.AvatarRendererMixin" + //?} + //? if >= 1.21.10 { + , + "client.cosmetics.Mixin_WaveyCapesPreviewSkip", + "client.cosmetics.Mixin_WaveyCapesPreviewCape" + //?} + //? if >= 1.21.4 && < 1.21.5 { + /*, + "client.cosmetics.EntityRenderDispatcherPlayerAccessor" + *///?} ], "verbose": true } From 5d6b4cbce8812364de6bf289ab66ae4b5fe7a3fb Mon Sep 17 00:00:00 2001 From: awruff Date: Tue, 21 Jul 2026 21:35:02 -0400 Subject: [PATCH 3/4] feat: polish up account switcher --- .../client/access/MinecraftAccessor.java | 2 + .../client/gui/PolyPlusMainMenuScreen.kt | 345 +++++++++++++++++- .../client/gui/preview/PlayerPreview.kt | 23 +- .../gui/preview/PlayerPreviewRenderer.kt | 48 ++- .../polyplus/client/launcher/MicrosoftAuth.kt | 297 +++++++++++++-- .../client/launcher/MicrosoftAuthError.kt | 232 ++++++++++++ .../client/launcher/OneLauncherAccounts.kt | 39 +- .../assets/polyplus/mainmenu/copy-01.svg | 3 + .../assets/polyplus/mainmenu/key-01.svg | 3 + .../polyplus/mainmenu/link-external-01.svg | 3 + .../assets/polyplus/mainmenu/loading-02.svg | 3 + .../polyplus/mainmenu/refresh-cw-01.svg | 3 + 12 files changed, 964 insertions(+), 37 deletions(-) create mode 100644 src/main/kotlin/org/polyfrost/polyplus/client/launcher/MicrosoftAuthError.kt create mode 100644 src/main/resources/assets/polyplus/mainmenu/copy-01.svg create mode 100644 src/main/resources/assets/polyplus/mainmenu/key-01.svg create mode 100644 src/main/resources/assets/polyplus/mainmenu/link-external-01.svg create mode 100644 src/main/resources/assets/polyplus/mainmenu/loading-02.svg create mode 100644 src/main/resources/assets/polyplus/mainmenu/refresh-cw-01.svg diff --git a/src/main/java/org/polyfrost/polyplus/mixin/client/access/MinecraftAccessor.java b/src/main/java/org/polyfrost/polyplus/mixin/client/access/MinecraftAccessor.java index d42011f..a910289 100644 --- a/src/main/java/org/polyfrost/polyplus/mixin/client/access/MinecraftAccessor.java +++ b/src/main/java/org/polyfrost/polyplus/mixin/client/access/MinecraftAccessor.java @@ -3,10 +3,12 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.User; import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Mutable; import org.spongepowered.asm.mixin.gen.Accessor; @Mixin(Minecraft.class) public interface MinecraftAccessor { + @Mutable @Accessor("user") void setUser(User user); } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusMainMenuScreen.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusMainMenuScreen.kt index b58e789..a4295cd 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusMainMenuScreen.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/PolyPlusMainMenuScreen.kt @@ -2,7 +2,12 @@ package org.polyfrost.polyplus.client.gui import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.expandVertically @@ -42,6 +47,7 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -108,6 +114,7 @@ import org.polyfrost.oneconfig.internal.ui.components.NotificationsCenter import org.polyfrost.oneconfig.internal.ui.compose.ComposeScreen import org.polyfrost.polyplus.client.PolyPlusConfig import org.polyfrost.polyplus.client.host.E4mcSupport +import org.polyfrost.polyplus.client.launcher.MicrosoftAuthException import org.polyfrost.polyplus.client.launcher.OneLauncherAccounts import org.polyfrost.polyplus.client.host.HostWorldManager import org.polyfrost.oneconfig.internal.ui.themes.Accent @@ -761,6 +768,11 @@ private fun HostWorldPopup( var gameMode by remember { mutableStateOf(net.minecraft.world.level.GameType.SURVIVAL) } var allowCheats by remember { mutableStateOf(false) } + DisposableEffect(Unit) { + org.polyfrost.polyplus.client.gui.preview.PlayerPreviewDim.push() + onDispose { org.polyfrost.polyplus.client.gui.preview.PlayerPreviewDim.pop() } + } + LaunchedEffect(Unit) { val loaded = HostWorldManager.loadWorlds() worlds = loaded @@ -1152,6 +1164,9 @@ private fun AccountPill(name: String, assetsReady: Boolean) { var accounts by remember { mutableStateOf?>(null) } var busy by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } + var errorSteps by remember { mutableStateOf?>(null) } + var loginSession by remember { mutableStateOf(null) } + var loginJob by remember { mutableStateOf(null) } var pillSize by remember { mutableStateOf(IntSize.Zero) } var pillBounds by remember { mutableStateOf(androidx.compose.ui.geometry.Rect.Zero) } @@ -1162,6 +1177,7 @@ private fun AccountPill(name: String, assetsReady: Boolean) { LaunchedEffect(open) { if (open) { error = null + errorSteps = null reload() } } @@ -1171,6 +1187,7 @@ private fun AccountPill(name: String, assetsReady: Boolean) { scope.launch { busy = "Switching…" error = null + errorSteps = null val ok = withContext(Dispatchers.IO) { OneLauncherAccounts.switchTo(account.id) } if (!ok) error = "Couldn't switch to that account" reload() @@ -1179,12 +1196,57 @@ private fun AccountPill(name: String, assetsReady: Boolean) { } } val onAddMicrosoft: () -> Unit = { + if (busy == null) { + loginJob = scope.launch { + error = null + errorSteps = null + busy = "Starting Microsoft sign-in…" + val session = runCatching { withContext(Dispatchers.IO) { OneLauncherAccounts.beginLogin() } } + .onFailure { + error = it.message ?: "Couldn't start sign-in" + errorSteps = (it as? MicrosoftAuthException)?.stepsToFix?.takeIf { steps -> steps.isNotEmpty() } + } + .getOrNull() + if (session == null) { + busy = null + loginJob = null + return@launch + } + loginSession = session + ClientPlatform.openUri(session.browserAuthUrl) + busy = "Waiting for you to finish signing in…" + runCatching { withContext(Dispatchers.IO) { OneLauncherAccounts.finishLogin(session) } } + .onFailure { + error = it.message ?: "Microsoft sign-in failed" + errorSteps = (it as? MicrosoftAuthException)?.stepsToFix?.takeIf { steps -> steps.isNotEmpty() } + } + loginSession = null + loginJob = null + reload() + busy = null + } + } + } + val onCancelLogin: () -> Unit = { + loginJob?.cancel() + loginSession?.let { runCatching { OneLauncherAccounts.cancelLogin(it) } } + loginJob = null + loginSession = null + busy = null + error = null + errorSteps = null + } + val onRefresh: (OneLauncherAccounts.Account) -> Unit = { account -> if (busy == null) { scope.launch { - busy = "Finish signing in in your browser…" + busy = "Refreshing session…" error = null - runCatching { withContext(Dispatchers.IO) { OneLauncherAccounts.addMicrosoft() } } - .onFailure { error = it.message ?: "Microsoft sign-in failed" } + errorSteps = null + runCatching { withContext(Dispatchers.IO) { OneLauncherAccounts.refresh(account.id) } } + .onFailure { + error = it.message ?: "Couldn't refresh that account" + errorSteps = (it as? MicrosoftAuthException)?.stepsToFix?.takeIf { steps -> steps.isNotEmpty() } + } reload() busy = null } @@ -1195,6 +1257,7 @@ private fun AccountPill(name: String, assetsReady: Boolean) { scope.launch { busy = "Adding account…" error = null + errorSteps = null runCatching { withContext(Dispatchers.IO) { OneLauncherAccounts.addOffline(username) } } .onFailure { error = it.message ?: "Couldn't add that account" } reload() @@ -1207,6 +1270,7 @@ private fun AccountPill(name: String, assetsReady: Boolean) { scope.launch { busy = "Removing account…" error = null + errorSteps = null val ok = withContext(Dispatchers.IO) { OneLauncherAccounts.remove(account.id) } if (!ok) error = "Couldn't remove that account" reload() @@ -1282,13 +1346,28 @@ private fun AccountPill(name: String, assetsReady: Boolean) { assetsReady = assetsReady, busy = busy, error = error, + errorSteps = errorSteps, + loginSession = loginSession, onSwitch = onSwitch, onRemove = onRemove, + onRefresh = onRefresh, onAddMicrosoft = onAddMicrosoft, + onCancelLogin = onCancelLogin, onAddOffline = onAddOffline, ) } } + val session = loginSession + if (session != null) { + MicrosoftLoginPopup( + code = session.userCode, + verificationUri = session.verificationUri, + browserAuthUrl = session.browserAuthUrl, + status = busy, + assetsReady = assetsReady, + onCancel = onCancelLogin, + ) + } } } @@ -1300,9 +1379,13 @@ private fun AccountSwitcherPanel( assetsReady: Boolean, busy: String?, error: String?, + errorSteps: List?, + loginSession: org.polyfrost.polyplus.client.launcher.MicrosoftAuth.MicrosoftLoginSession?, onSwitch: (OneLauncherAccounts.Account) -> Unit, onRemove: (OneLauncherAccounts.Account) -> Unit, + onRefresh: (OneLauncherAccounts.Account) -> Unit, onAddMicrosoft: () -> Unit, + onCancelLogin: () -> Unit, onAddOffline: (String) -> Unit, ) { var offlineEntry by remember { mutableStateOf(false) } @@ -1357,6 +1440,7 @@ private fun AccountSwitcherPanel( enabled = idle, onClick = { onSwitch(account) }, onRemove = { onRemove(account) }, + onRefresh = { onRefresh(account) }, ) } } @@ -1396,6 +1480,28 @@ private fun AccountSwitcherPanel( if (error != null) { MenuText(error, fontSize = 12.sp, color = DangerColor, modifier = Modifier.align(Alignment.Start)) } + if (!errorSteps.isNullOrEmpty()) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + MenuText( + "What you can do:", + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.align(Alignment.Start), + ) + errorSteps.forEach { step -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + MenuText("•", fontSize = 12.sp, color = TextSecondary) + MenuText(step, fontSize = 12.sp, color = TextSecondary) + } + } + } + } } } @@ -1406,6 +1512,7 @@ private fun AccountRow( enabled: Boolean, onClick: () -> Unit, onRemove: () -> Unit, + onRefresh: () -> Unit, ) { val interaction = remember { MutableInteractionSource() } val hovered by interaction.collectIsHoveredAsState() @@ -1439,6 +1546,7 @@ private fun AccountRow( ) val subtitle = when { confirmRemove -> "Remove this account?" + account.expired -> "Session expired" account.active -> "Active" account.microsoft -> "Microsoft" else -> "Offline" @@ -1446,7 +1554,11 @@ private fun AccountRow( MenuText( subtitle, fontSize = 11.sp, - color = if (confirmRemove) DangerColor else TextSecondary, + color = when { + confirmRemove -> DangerColor + account.expired -> WarnColor + else -> TextSecondary + }, modifier = Modifier.align(Alignment.Start), ) } @@ -1460,6 +1572,9 @@ private fun AccountRow( if (account.active) { MenuIcon(ASSETS + "check-circle.svg", SuccessColor, Modifier.size(18.dp), assetsReady) } + if (account.expired) { + AccountActionIcon(ASSETS + "refresh-cw-01.svg", WarnColor, enabled, onRefresh) + } if (hovered) { AccountActionIcon(ASSETS + "trash-01.svg", TextSecondary, enabled) { confirmRemove = true } } @@ -1541,6 +1656,225 @@ private fun OfflineAccountEntry( } } +@Composable +private fun MicrosoftLoginPopup( + code: String, + verificationUri: String, + browserAuthUrl: String, + status: String?, + assetsReady: Boolean, + onCancel: () -> Unit, +) { + DisposableEffect(Unit) { + org.polyfrost.polyplus.client.gui.preview.PlayerPreviewDim.push() + onDispose { org.polyfrost.polyplus.client.gui.preview.PlayerPreviewDim.pop() } + } + Popup( + alignment = Alignment.Center, + onDismissRequest = onCancel, + properties = PopupProperties(focusable = true), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Scrim) + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onCancel() }, + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier + .width(380.dp) + .clip(PanelShape) + .background(PageBackground.copy(alpha = 0.96f)) + .border(BorderWidth, PanelBorderBrush, PanelShape) + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {} + .padding(horizontal = 22.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + MenuText("Sign in to Microsoft", fontSize = 22.sp, fontWeight = FontWeight.Bold, fontFamily = if (assetsReady) Outfit else FontFamily.Default) + MenuText( + "We opened the Microsoft sign-in page in your browser. Finish there and you'll be brought back automatically.", + fontSize = 13.sp, + color = TextSecondary, + ) + LoginModalButton( + label = "Open in browser again", + icon = ASSETS + "link-external-01.svg", + filled = true, + modifier = Modifier.fillMaxWidth(), + assetsReady = assetsReady, + onClick = { ClientPlatform.openUri(browserAuthUrl) }, + ) + OrDivider() + MenuText( + "Or enter this code at the Microsoft sign-in page:", + fontSize = 13.sp, + color = TextSecondary, + ) + Box( + modifier = Modifier + .fillMaxWidth() + .clip(ppShape(8.dp)) + .background(LocalTheme.current.componentBackground.copy(alpha = 0.5f)) + .border(BorderWidth, Accent, ppShape(8.dp)) + .padding(vertical = 20.dp), + contentAlignment = Alignment.Center, + ) { + MenuText(code, fontSize = 30.sp, fontWeight = FontWeight.Bold, letterSpacing = 2.sp, fontFamily = if (assetsReady) Outfit else FontFamily.Default) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + LoginModalButton( + label = "Copy code", + icon = ASSETS + "copy-01.svg", + filled = false, + modifier = Modifier.weight(1f), + assetsReady = assetsReady, + onClick = { + runCatching { + net.minecraft.client.Minecraft.getInstance().keyboardHandler.setClipboard(code) + } + }, + ) + LoginModalButton( + label = "Open in browser", + icon = ASSETS + "link-external-01.svg", + filled = true, + modifier = Modifier.weight(1f), + assetsReady = assetsReady, + onClick = { ClientPlatform.openUri(verificationUri) }, + ) + } + if (status != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + LoadingSpinner(Modifier.size(14.dp)) + MenuText(status, fontSize = 13.sp, color = TextSecondary) + } + } + Box( + modifier = Modifier + .clip(ppShape(6.dp)) + .clickableWithSound(onCancel) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + MenuText("Cancel", fontSize = 14.sp, color = TextPrimary) + } + } + } + } +} + +@Composable +private fun LoginModalButton( + label: String, + icon: String, + filled: Boolean, + modifier: Modifier = Modifier, + assetsReady: Boolean, + onClick: () -> Unit, +) { + val contentColor = if (filled) Color.White else TextPrimary + Row( + modifier = modifier + .height(44.dp) + .clip(PanelShape) + .background(if (filled) Accent else PanelBackground) + .border(BorderWidth, if (filled) SolidColor(Accent) else PanelBorderBrush, PanelShape) + .clickableWithSound(onClick) + .padding(horizontal = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + MenuIcon(icon, contentColor, Modifier.size(16.dp), assetsReady) + MenuText(label, fontSize = 14.sp, color = contentColor, maxLines = 1) + } +} + +@Composable +private fun OrDivider() { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box(Modifier.weight(1f).height(1.dp).background(PanelBorderBrush)) + MenuText("or", fontSize = 12.sp, color = TextSecondary) + Box(Modifier.weight(1f).height(1.dp).background(PanelBorderBrush)) + } +} + +@Composable +private fun LoadingSpinner(modifier: Modifier) { + // OneClient's Loading02 spoke icon (static, like OneClient). + MenuIcon(ASSETS + "loading-02.svg", Accent, modifier, assetsReady = true) +} + +@Composable +private fun DeviceCodeCard( + code: String, + verificationUri: String, + assetsReady: Boolean, + onCancel: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + MenuText( + "We opened Microsoft sign-in in your browser. Finish there, or enter this code instead:", + fontSize = 12.sp, + color = TextSecondary, + ) + Box( + modifier = Modifier + .fillMaxWidth() + .clip(ppShape(8.dp)) + .background(LocalTheme.current.componentBackground.copy(alpha = 0.5f)) + .border(BorderWidth, Accent, ppShape(8.dp)) + .padding(vertical = 14.dp), + contentAlignment = Alignment.Center, + ) { + MenuText(code, fontSize = 30.sp, fontWeight = FontWeight.Bold, letterSpacing = 3.sp) + } + MenuText(verificationUri, fontSize = 11.sp, color = TextSecondary) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AddAccountButton( + icon = ASSETS + "copy-01.svg", + text = "Copy code", + enabled = true, + assetsReady = assetsReady, + onClick = { + runCatching { + net.minecraft.client.Minecraft.getInstance().keyboardHandler.setClipboard(code) + } + }, + ) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AddAccountButton( + icon = ASSETS + "link-external-01.svg", + text = "Open in browser", + enabled = true, + assetsReady = assetsReady, + onClick = { ClientPlatform.openUri(verificationUri) }, + ) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AddAccountButton( + icon = ASSETS + "x-close.svg", + text = "Cancel", + enabled = true, + assetsReady = assetsReady, + onClick = onCancel, + ) + } + } +} + @Composable private fun AccountActionIcon(icon: String, color: Color, enabled: Boolean, onClick: () -> Unit) { Box( @@ -1609,10 +1943,13 @@ private fun MenuText( fontWeight: FontWeight = FontWeight.Normal, letterSpacing: TextUnit = TextUnit.Unspecified, fontFamily: FontFamily = LocalTheme.current.typography.family, + maxLines: Int = Int.MAX_VALUE, ) { BasicText( text = text, modifier = modifier, + maxLines = maxLines, + softWrap = maxLines != 1, style = TextStyle( color = color, fontSize = fontSize, diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreview.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreview.kt index abe7295..cc06861 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreview.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreview.kt @@ -30,6 +30,26 @@ import kotlinx.coroutines.delay val LocalPlayerPreviewOpacity = androidx.compose.runtime.compositionLocalOf { 1f } +object PlayerPreviewDim { + private const val DIMMED = 0.18f + var factor by mutableStateOf(1f) + private set + private var depth = 0 + + fun push() { + depth++ + factor = DIMMED + } + + fun pop() { + depth-- + if (depth <= 0) { + depth = 0 + factor = 1f + } + } +} + @Composable fun PlayerPreview( modifier: Modifier = Modifier, @@ -68,7 +88,8 @@ private fun PlayerPreviewLive( ) { val entry = remember { PlayerPreviewOverlay.register() } val overlayOpacity = LocalPlayerPreviewOpacity.current * - org.polyfrost.oneconfig.internal.ui.LocalOneConfigContentAlpha.current + org.polyfrost.oneconfig.internal.ui.LocalOneConfigContentAlpha.current * + PlayerPreviewDim.factor androidx.compose.runtime.DisposableEffect(entry) { onDispose { PlayerPreviewOverlay.reportBounds(entry, 0f, 0f, 0f, 0f, visible = false) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewRenderer.kt b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewRenderer.kt index 7c58a75..395f468 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewRenderer.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/gui/preview/PlayerPreviewRenderer.kt @@ -287,6 +287,7 @@ object PlayerPreviewRenderer { private var target: TextureTarget? = null private var dummy: AbstractClientPlayer? = null + private var dummyProfileId: java.util.UUID? = null //? if < 26.1 { /*private val projection by lazy { CachedOrthoProjectionMatrixBuffer("polyplus_preview", -1000f, 1000f, true) } *///?} @@ -469,7 +470,7 @@ object PlayerPreviewRenderer { //?} pass.setIndexBuffer(ibuf, itype) //? if >= 26.2 { - /*pass.drawIndexed(0, 0, indexCount, 0, 1) + /*pass.drawIndexed(indexCount, 1, 0, 0, 0) *///?} else { pass.drawIndexed(0, 0, indexCount, 1) //?} @@ -650,15 +651,48 @@ object PlayerPreviewRenderer { PlayerSkin(skin.texture(), skin.textureUrl(), cape, skin.elytraTexture(), skin.model(), skin.secure()) *///?} + @Volatile + private var resolvedProfile: com.mojang.authlib.GameProfile? = null + private var resolvingProfileId: java.util.UUID? = null + + private fun texturedProfile(mc: Minecraft): com.mojang.authlib.GameProfile? { + val id = mc.user.profileId + val startup = mc.gameProfile + if (startup.id == id) return startup // no switch: startup profile already carries textures + resolvedProfile?.let { if (it.id == id) return it } + synchronized(this) { + if (resolvingProfileId != id) { + resolvingProfileId = id + val name = mc.user.name + org.polyfrost.polyplus.client.PolyPlusClient.SCOPE.launch { + val fetched = runCatching { + //? if >= 1.21.10 { + mc.services().sessionService().fetchProfile(id, false)?.profile() + //?} else { + /*mc.minecraftSessionService.fetchProfile(id, false)?.profile() + *///?} + }.getOrNull() ?: com.mojang.authlib.GameProfile(id, name) + synchronized(this@PlayerPreviewRenderer) { + if (resolvingProfileId == id) { + resolvedProfile = fetched + resolvingProfileId = null + } + } + } + } + } + return null + } + //? if >= 1.21.10 { private fun localSkin(mc: Minecraft): PlayerSkin? { - val profile = mc.gameProfile + val profile = texturedProfile(mc) ?: return DefaultPlayerSkin.get(com.mojang.authlib.GameProfile(mc.user.profileId, mc.user.name)) return runCatching { mc.skinManager.createLookup(profile, false).get() }.getOrNull() ?: runCatching { DefaultPlayerSkin.get(profile) }.getOrNull() } //?} else { /*private fun localSkin(mc: Minecraft): PlayerSkin? { - val profile = mc.gameProfile + val profile = texturedProfile(mc) ?: return DefaultPlayerSkin.get(com.mojang.authlib.GameProfile(mc.user.profileId, mc.user.name)) return runCatching { mc.skinManager.getInsecureSkin(profile) }.getOrNull() ?: runCatching { DefaultPlayerSkin.get(profile) }.getOrNull() } @@ -710,10 +744,14 @@ object PlayerPreviewRenderer { private const val PLAYER_BB_HEIGHT = 1.8f private fun dummy(mc: Minecraft, level: ClientLevel): AbstractClientPlayer? { - dummy?.let { return it } - return runCatching { RemotePlayer(level, mc.gameProfile) }.getOrNull()?.also { + // Skip the frame while the textured profile resolves; a bare-profile dummy would cache the + // default skin and never refresh (same uuid), whereas retrying picks up textures once ready. + val profile = texturedProfile(mc) ?: return null + dummy?.let { if (dummyProfileId == profile.id && it.level() === level) return it } + return runCatching { RemotePlayer(level, profile) }.getOrNull()?.also { it.id = PREVIEW_ENTITY_ID dummy = it + dummyProfileId = profile.id } } diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/launcher/MicrosoftAuth.kt b/src/main/kotlin/org/polyfrost/polyplus/client/launcher/MicrosoftAuth.kt index c6a2a5a..3b7b086 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/launcher/MicrosoftAuth.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/launcher/MicrosoftAuth.kt @@ -9,15 +9,23 @@ import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.Parameters import io.ktor.http.contentType import io.ktor.http.formUrlEncode +import io.ktor.http.isSuccess import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.selects.select import kotlinx.coroutines.withTimeout import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import kotlinx.serialization.json.add import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -25,7 +33,7 @@ import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonObject import org.apache.logging.log4j.LogManager import org.polyfrost.polyplus.client.PolyPlusClient -import org.polyfrost.polyplus.client.utils.ClientPlatform +import java.io.IOException import java.net.InetAddress import java.net.InetSocketAddress import java.net.URLDecoder @@ -35,7 +43,7 @@ import java.security.SecureRandom import java.time.Instant import java.util.Base64 import java.util.UUID -import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds object MicrosoftAuth { private val LOGGER = LogManager.getLogger("PolyPlus/Accounts") @@ -44,13 +52,36 @@ object MicrosoftAuth { private const val SCOPES = "XboxLive.SignIn XboxLive.offline_access" private const val AUTHORIZE_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize" private const val TOKEN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token" + private const val DEVICE_CODE_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode" + private const val DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code" private const val LOGIN_TIMEOUT_MS = 5 * 60 * 1000L private val HTTP get() = PolyPlusClient.HTTP private val B64URL = Base64.getUrlEncoder().withoutPadding() + private val ERR_JSON = Json { ignoreUnknownKeys = true } - suspend fun login(): LauncherAccountStore.StoredAccount { + class DeviceCode( + val userCode: String, + val verificationUri: String, + val deviceCode: String, + val expiresIn: Long, + val interval: Long, + val message: String, + ) + + class MicrosoftLoginSession internal constructor( + val browserAuthUrl: String, + val userCode: String, + val verificationUri: String, + internal val server: HttpServer, + internal val redirectUri: String, + internal val verifier: String, + internal val device: DeviceCode, + internal val codeResult: CompletableDeferred, + ) + + suspend fun beginLogin(): MicrosoftLoginSession { val verifier = randomToken() val challenge = pkceChallenge(verifier) val csrfState = randomToken() @@ -63,13 +94,170 @@ object MicrosoftAuth { server.executor = null server.start() + val device = try { + beginDeviceLogin() + } catch (e: Throwable) { + server.stop(0) + throw e + } + + return MicrosoftLoginSession( + browserAuthUrl = authorizeUrl(redirectUri, csrfState, challenge), + userCode = device.userCode, + verificationUri = device.verificationUri, + server = server, + redirectUri = redirectUri, + verifier = verifier, + device = device, + codeResult = codeResult, + ) + } + + suspend fun finishLogin(session: MicrosoftLoginSession): LauncherAccountStore.StoredAccount { try { - ClientPlatform.openUri(authorizeUrl(redirectUri, csrfState, challenge)) - val code = withTimeout(LOGIN_TIMEOUT_MS.milliseconds) { codeResult.await() } - val msa = exchangeAuthCode(code, redirectUri, verifier) + val timeout = session.device.expiresIn.coerceAtMost(LOGIN_TIMEOUT_MS / 1000).seconds + val msa = withTimeout(timeout) { raceLogin(session) } return accountFromMsa(msa) } finally { - server.stop(0) + session.server.stop(0) + } + } + + fun cancelLogin(session: MicrosoftLoginSession) { + session.server.stop(0) + } + + private suspend fun raceLogin(session: MicrosoftLoginSession): MsaToken = coroutineScope { + val browser = async { + runCatching { + val code = session.codeResult.await() + exchangeAuthCode(code, session.redirectUri, session.verifier) + } + } + val device = async { runCatching { pollDeviceToken(session.device) } } + try { + var browserDone = false + var deviceDone = false + var lastError: Throwable? = null + while (true) { + val result: Result = select { + if (!browserDone) browser.onAwait { browserDone = true; it } + if (!deviceDone) device.onAwait { deviceDone = true; it } + } + result.onSuccess { return@coroutineScope it } + result.onFailure { lastError = it } + if (browserDone && deviceDone) break + } + throw lastError ?: MicrosoftAuthErrors.deviceExpired() + } finally { + browser.cancel() + device.cancel() + } + } + + suspend fun refreshAccount(account: LauncherAccountStore.StoredAccount): LauncherAccountStore.StoredAccount { + val msa = refreshMsaToken(account.refreshToken) + return accountFromMsa(msa) + } + + private suspend fun refreshMsaToken(refreshToken: String): MsaToken { + if (refreshToken.isBlank()) throw MicrosoftAuthErrors.forStep(MsaAuthStep.AuthCodeExchange) + val response = try { + HTTP.post(TOKEN_URL) { + accept(ContentType.Application.Json) + setBody( + FormDataContent( + Parameters.build { + append("client_id", CLIENT_ID) + append("grant_type", "refresh_token") + append("refresh_token", refreshToken) + append("scope", SCOPES) + }, + ), + ) + } + } catch (e: IOException) { + throw MicrosoftAuthErrors.network(e) + } catch (e: java.nio.channels.UnresolvedAddressException) { + throw MicrosoftAuthErrors.network(e) + } + if (!response.status.isSuccess()) { + throw MicrosoftAuthErrors.forStep(MsaAuthStep.AuthCodeExchange) + } + val body: MsaTokenResponse = response.body() + return MsaToken(body.accessToken, body.refreshToken.ifBlank { refreshToken }, body.expiresIn, Instant.now()) + } + + private suspend fun beginDeviceLogin(): DeviceCode { + val response = try { + HTTP.post(DEVICE_CODE_URL) { + accept(ContentType.Application.Json) + setBody( + FormDataContent( + Parameters.build { + append("client_id", CLIENT_ID) + append("scope", SCOPES) + }, + ), + ) + } + } catch (e: IOException) { + throw MicrosoftAuthErrors.network(e) + } catch (e: java.nio.channels.UnresolvedAddressException) { + throw MicrosoftAuthErrors.network(e) + } + if (!response.status.isSuccess()) { + throw MicrosoftAuthErrors.forStep(MsaAuthStep.DeviceCodeRequest) + } + val body: DeviceCodeResponse = response.body() + return DeviceCode( + userCode = body.userCode, + verificationUri = body.verificationUri, + deviceCode = body.deviceCode, + expiresIn = body.expiresIn, + interval = body.interval, + message = body.message, + ) + } + + private suspend fun pollDeviceToken(device: DeviceCode): MsaToken { + var intervalSeconds = device.interval.coerceAtLeast(5) + while (true) { + val response = try { + HTTP.post(TOKEN_URL) { + accept(ContentType.Application.Json) + setBody( + FormDataContent( + Parameters.build { + append("grant_type", DEVICE_GRANT_TYPE) + append("client_id", CLIENT_ID) + append("device_code", device.deviceCode) + }, + ), + ) + } + } catch (e: IOException) { + throw MicrosoftAuthErrors.network(e) + } catch (e: java.nio.channels.UnresolvedAddressException) { + throw MicrosoftAuthErrors.network(e) + } + if (response.status.isSuccess()) { + val body: MsaTokenResponse = response.body() + return MsaToken(body.accessToken, body.refreshToken, body.expiresIn, Instant.now()) + } + val error = runCatching { + ERR_JSON.decodeFromString(OAuthErrorResponse.serializer(), response.bodyAsText()) + }.getOrNull() + when (error?.error) { + "authorization_pending" -> delay(intervalSeconds.seconds) + "slow_down" -> { + intervalSeconds += 5 + delay(intervalSeconds.seconds) + } + "expired_token" -> throw MicrosoftAuthErrors.deviceExpired() + null -> throw MicrosoftAuthErrors.forStep(MsaAuthStep.DeviceCodePoll) + else -> throw MicrosoftAuthErrors.deviceFailed(error.errorDescription ?: error.error) + } } } @@ -96,7 +284,16 @@ object MicrosoftAuth { val query = exchange.requestURI.rawQuery?.let(::parseQuery).orEmpty() val page: RedirectPage = when { query["error"] != null -> { - result.completeExceptionally(IllegalStateException("Microsoft sign-in failed: ${query["error"]}")) + result.completeExceptionally( + MicrosoftAuthException( + "The Microsoft sign-in was cancelled or rejected in your browser (${query["error"]}).", + listOf( + "Start the sign-in again", + "Complete the Microsoft sign-in in your browser without closing the window", + "If your browser blocked the redirect back, allow it and try once more", + ), + ), + ) RedirectPage.FAILED } query["state"] != null && query["state"] != expectedState -> RedirectPage.FAILED @@ -114,7 +311,7 @@ object MicrosoftAuth { redirectUri: String, verifier: String, ): MsaToken { - val response: MsaTokenResponse = HTTP.post(TOKEN_URL) { + val response = HTTP.post(TOKEN_URL) { accept(ContentType.Application.Json) setBody( FormDataContent( @@ -128,15 +325,20 @@ object MicrosoftAuth { }, ), ) - }.body() - return MsaToken(response.accessToken, response.refreshToken, response.expiresIn, Instant.now()) + } + if (!response.status.isSuccess()) { + throw MicrosoftAuthErrors.forStep(MsaAuthStep.AuthCodeExchange) + } + val body: MsaTokenResponse = response.body() + return MsaToken(body.accessToken, body.refreshToken, body.expiresIn, Instant.now()) } private suspend fun accountFromMsa(msa: MsaToken): LauncherAccountStore.StoredAccount { val xblToken = xblAuthenticate("d=${msa.accessToken}") val xsts = xstsAuthorize(xblToken) - val xstsToken = xsts.token ?: error("XSTS returned no token") - val uhs = xsts.displayClaims?.xui?.firstOrNull()?.uhs ?: error("XSTS returned no user hash") + val xstsToken = xsts.token ?: throw MicrosoftAuthErrors.forStep(MsaAuthStep.XstsAuthorize) + val uhs = xsts.displayClaims?.xui?.firstOrNull()?.uhs + ?: throw MicrosoftAuthErrors.forStep(MsaAuthStep.XstsAuthorize) val mcToken = minecraftLogin(uhs, xstsToken) minecraftEntitlements(mcToken) @@ -162,13 +364,14 @@ object MicrosoftAuth { put("RelyingParty", "http://auth.xboxlive.com") put("TokenType", "JWT") } - val res: XboxTokenResponse = HTTP.post("https://user.auth.xboxlive.com/user/authenticate") { + val response = HTTP.post("https://user.auth.xboxlive.com/user/authenticate") { accept(ContentType.Application.Json) header("x-xbl-contract-version", "1") contentType(ContentType.Application.Json) setBody(body) - }.body() - return res.token ?: error("Xbox Live returned no token") + } + val res = parseXboxResponse(response, MsaAuthStep.XblAuthenticate) + return res.token ?: throw MicrosoftAuthErrors.forStep(MsaAuthStep.XblAuthenticate) } private suspend fun xstsAuthorize(userToken: String): XboxTokenResponse { @@ -180,22 +383,40 @@ object MicrosoftAuth { put("RelyingParty", "rp://api.minecraftservices.com/") put("TokenType", "JWT") } - return HTTP.post("https://xsts.auth.xboxlive.com/xsts/authorize") { + val response = HTTP.post("https://xsts.auth.xboxlive.com/xsts/authorize") { accept(ContentType.Application.Json) header("x-xbl-contract-version", "1") contentType(ContentType.Application.Json) setBody(body) - }.body() + } + return parseXboxResponse(response, MsaAuthStep.XstsAuthorize) + } + + private suspend fun parseXboxResponse(response: HttpResponse, step: MsaAuthStep): XboxTokenResponse { + val text = response.bodyAsText() + if (response.status.isSuccess()) { + return ERR_JSON.decodeFromString(XboxTokenResponse.serializer(), text) + } + val xerr = runCatching { + ERR_JSON.decodeFromString(XboxErrorResponse.serializer(), text).xErr + }.getOrNull() + if (xerr != null && xerr != 0L) { + throw MicrosoftAuthErrors.forXerr(xerr) + } + throw MicrosoftAuthErrors.forService(step, response.status.value) } private suspend fun minecraftLogin(uhs: String, xstsToken: String): String { val body = buildJsonObject { put("identityToken", "XBL3.0 x=$uhs;$xstsToken") } - val res: MinecraftTokenResponse = HTTP.post("https://api.minecraftservices.com/authentication/login_with_xbox") { + val response = HTTP.post("https://api.minecraftservices.com/authentication/login_with_xbox") { accept(ContentType.Application.Json) contentType(ContentType.Application.Json) setBody(body) - }.body() - return res.accessToken + } + if (!response.status.isSuccess()) { + throw MicrosoftAuthErrors.forService(MsaAuthStep.MinecraftToken, response.status.value) + } + return response.body().accessToken } private suspend fun minecraftEntitlements(token: String) { @@ -207,11 +428,16 @@ object MicrosoftAuth { }.onFailure { LOGGER.debug("entitlements check failed", it) } } - private suspend fun minecraftProfile(token: String): MinecraftProfile = - HTTP.get("https://api.minecraftservices.com/minecraft/profile") { + private suspend fun minecraftProfile(token: String): MinecraftProfile { + val response = HTTP.get("https://api.minecraftservices.com/minecraft/profile") { accept(ContentType.Application.Json) header(HttpHeaders.Authorization, "Bearer $token") - }.body() + } + if (!response.status.isSuccess()) { + throw MicrosoftAuthErrors.forService(MsaAuthStep.MinecraftProfile, response.status.value) + } + return response.body() + } private fun randomToken(): String { val bytes = ByteArray(32) @@ -321,6 +547,22 @@ object MicrosoftAuth { val obtainedAt: Instant, ) + @Serializable + private data class DeviceCodeResponse( + @SerialName("user_code") val userCode: String, + @SerialName("device_code") val deviceCode: String, + @SerialName("verification_uri") val verificationUri: String, + @SerialName("expires_in") val expiresIn: Long = 900, + @SerialName("interval") val interval: Long = 5, + @SerialName("message") val message: String = "", + ) + + @Serializable + private data class OAuthErrorResponse( + @SerialName("error") val error: String, + @SerialName("error_description") val errorDescription: String? = null, + ) + @Serializable private data class MsaTokenResponse( @SerialName("access_token") val accessToken: String, @@ -334,6 +576,13 @@ object MicrosoftAuth { @SerialName("DisplayClaims") val displayClaims: DisplayClaims? = null, ) + @Serializable + private data class XboxErrorResponse( + @SerialName("XErr") val xErr: Long = 0, + @SerialName("Message") val message: String = "", + @SerialName("Redirect") val redirect: String? = null, + ) + @Serializable private data class DisplayClaims(val xui: List = emptyList()) diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/launcher/MicrosoftAuthError.kt b/src/main/kotlin/org/polyfrost/polyplus/client/launcher/MicrosoftAuthError.kt new file mode 100644 index 0000000..9e930e8 --- /dev/null +++ b/src/main/kotlin/org/polyfrost/polyplus/client/launcher/MicrosoftAuthError.kt @@ -0,0 +1,232 @@ +package org.polyfrost.polyplus.client.launcher + +enum class MsaAuthStep { + DeviceCodeRequest, + DeviceCodePoll, + AuthCodeExchange, + XblAuthenticate, + XstsAuthorize, + MinecraftToken, + MinecraftProfile, +} + +class MicrosoftAuthException( + val whatHappened: String, + val stepsToFix: List, + cause: Throwable? = null, +) : Exception(whatHappened, cause) + +object MicrosoftAuthErrors { + fun friendlyXboxError(code: Long): String? = when (code) { + 2_148_916_227L -> "This account has been banned or suspended from Xbox." + 2_148_916_229L -> "This account is a child account that must be added to a Family group by an adult before signing in." + 2_148_916_233L -> "This Microsoft account does not have an Xbox profile yet. Create one at xbox.com, then try again." + 2_148_916_234L -> "This account has not accepted the Xbox Terms of Service. Sign in at xbox.com to accept them first." + 2_148_916_235L -> "Xbox Live is not available in your country or region, so sign-in is blocked." + 2_148_916_236L, 2_148_916_237L -> "This account requires adult verification (South Korea) before it can sign in." + 2_148_916_238L -> "This is a child account. An adult must add it to a Microsoft Family group before it can sign in." + else -> null + } + + fun forXerr(code: Long, cause: Throwable? = null): MicrosoftAuthException = when (code) { + 2_148_916_222L -> build( + "This account requires age verification to comply with UK regulations before it can sign in.", + listOf( + "Go to the Minecraft login page and sign in (https://www.minecraft.net/en-us/login)", + "Follow the instructions to verify your age", + "Once verified, try signing in again", + "For more help see UK age verification on Xbox (https://support.xbox.com/en-GB/help/family-online-safety/online-safety/UK-age-verification)", + ), + cause, + ) + 2_148_916_227L -> build( + "This account was suspended for violating Xbox Community Standards.", + listOf( + "Visit Xbox Support and review the enforcement details (https://support.xbox.com)", + "Submit an appeal if one is available", + ), + cause, + ) + 2_148_916_229L -> build( + "This account is restricted and does not have permission to play online.", + listOf( + "Have a guardian sign in to Microsoft Family (https://account.microsoft.com/family/)", + "Update the online play permissions", + "Once finished, try signing in again", + ), + cause, + ) + 2_148_916_233L -> build( + "This account does not have an Xbox profile set up, or does not own Minecraft.", + listOf( + "Make sure Minecraft is purchased on this account", + "Visit the Minecraft login page and sign in (https://www.minecraft.net/en-us/login)", + "Complete Xbox profile setup if prompted", + "Once finished, try signing in again", + ), + cause, + ) + 2_148_916_234L -> build( + "This account has not accepted Xbox's Terms of Service.", + listOf( + "Visit Xbox and sign in (https://www.xbox.com)", + "Accept the Terms if prompted", + "Once finished, try signing in again", + ), + cause, + ) + 2_148_916_235L -> build( + "Xbox Live is not available in your region, so sign-in is blocked.", + listOf( + "Xbox services must be supported in your country before you can sign in", + "Check Xbox availability for supported regions (https://www.xbox.com/en-US/regions)", + ), + cause, + ) + 2_148_916_236L, 2_148_916_237L -> build( + "This account requires adult verification under South Korean regulations.", + listOf( + "Visit Xbox and sign in (https://www.xbox.com)", + "Complete the identity verification process", + "Once finished, try signing in again", + ), + cause, + ) + 2_148_916_238L -> build( + "This account is underage and not linked to a Microsoft family group.", + listOf( + "Review the Minecraft Family Setup guide (https://help.minecraft.net/hc/en-us/articles/4408968616077)", + "Join or create a family group as instructed", + "Once finished, try signing in again", + ), + cause, + ) + else -> forStep(MsaAuthStep.XstsAuthorize, cause) + } + + fun forService(step: MsaAuthStep, statusCode: Int, cause: Throwable? = null): MicrosoftAuthException { + if (step == MsaAuthStep.MinecraftToken) { + if (statusCode == 429) { + return build( + "Microsoft or Minecraft temporarily blocked the sign-in because there were too many recent attempts.", + listOf( + "Wait about an hour before trying again", + "Restart Minecraft after waiting", + "Try signing in once more", + "If it keeps happening, wait longer before retrying so the temporary limit can clear", + ), + cause, + ) + } + if (statusCode in 500..599) { + return build( + "Minecraft's authentication service is returning a server error, so sign-in cannot finish right now.", + listOf( + "Wait a few minutes and try signing in again", + "Check Xbox status for current service issues (https://support.xbox.com/xbox-live-status)", + "Try the official Minecraft Launcher to confirm whether Minecraft sign-in is affected there too (https://www.minecraft.net/en-us/download)", + "If the service is healthy and this keeps happening, contact support with the debug information", + ), + cause, + ) + } + } + return forStep(step, cause) + } + + fun deviceExpired(cause: Throwable? = null): MicrosoftAuthException = build( + "The sign-in code expired before the Microsoft sign-in was finished.", + listOf( + "Start the sign-in again to get a fresh code", + "Open the link and enter the code promptly, before it expires", + "Once the Microsoft sign-in finishes, you'll be signed in automatically", + ), + cause, + ) + + fun deviceFailed(message: String, cause: Throwable? = null): MicrosoftAuthException = build( + "The Microsoft sign-in could not be completed ($message).", + listOf( + "Start the sign-in again", + "Open the link and enter the code, then approve the sign-in", + "Make sure you are signing in with the Microsoft account that owns Minecraft", + ), + cause, + ) + + fun forStep(step: MsaAuthStep, cause: Throwable? = null): MicrosoftAuthException = when (step) { + MsaAuthStep.DeviceCodeRequest -> build( + "PolyPlus could not start the Microsoft sign-in, so no sign-in code could be created.", + listOf( + "Check that your internet connection is working", + "Wait a moment and try signing in again", + "If it keeps happening, try the browser sign-in instead", + ), + cause, + ) + MsaAuthStep.DeviceCodePoll -> deviceExpired(cause) + MsaAuthStep.AuthCodeExchange -> build( + "Your saved Microsoft sign-in has expired or was revoked, so your Minecraft session could not be renewed.", + listOf( + "Start the sign-in again", + "Complete the Microsoft sign-in in your browser", + "Once the new sign-in finishes, try again", + ), + cause, + ) + MsaAuthStep.XblAuthenticate -> build( + "Xbox rejected the first sign-in step. This is most often caused by your system clock or time zone being out of sync, or by a temporary Xbox block.", + listOf( + "Open your system date and time settings", + "Turn on automatic time zone and automatic time, if available", + "Use the sync option in your settings to synchronise the clock", + "Restart Minecraft and try signing in again", + "If it persists, check Xbox status (https://support.xbox.com/xbox-live-status)", + ), + cause, + ) + MsaAuthStep.XstsAuthorize -> build( + "Xbox rejected the request to authorize this account for Minecraft, but did not return a specific account restriction we recognise.", + listOf( + "Sign in with the official Minecraft Launcher (https://www.minecraft.net/en-us/download)", + "Complete any prompts shown by Microsoft, Xbox, or Minecraft", + "Try signing in again", + "If the official launcher also fails, follow the error shown there or contact Xbox Support", + ), + cause, + ) + MsaAuthStep.MinecraftProfile -> build( + "Minecraft services could not return a Java Edition profile for this account. This usually means the game was purchased recently, the Java profile is not finished being created, or the wrong Microsoft account is being used.", + listOf( + "Sign in with the official Minecraft Launcher and launch Java Edition once (https://www.minecraft.net/en-us/download)", + "Wait up to an hour if the purchase or profile setup was recent", + "Make sure you are using the Microsoft account that owns Minecraft", + "Try signing in again", + ), + cause, + ) + MsaAuthStep.MinecraftToken -> build( + "Minecraft's authentication service could not finish signing you in.", + listOf( + "Wait a few minutes and try signing in again", + "Check Xbox status for current service issues (https://support.xbox.com/xbox-live-status)", + ), + cause, + ) + } + + fun network(cause: Throwable? = null): MicrosoftAuthException = build( + "PolyPlus could not connect to a Microsoft, Xbox, or Minecraft service needed for sign-in. This is usually a local network, DNS, proxy, firewall, hosts file, VPN, or antivirus issue.", + listOf( + "Check that your internet connection is working", + "Allow Minecraft through your firewall, antivirus, proxy, VPN, and hosts file rules", + "Try a different network, or temporarily disable VPN/proxy software if you use one", + "If routing or DNS is the issue, a service like Cloudflare WARP can sometimes help", + "Restart Minecraft and try signing in again", + ), + cause, + ) + + private fun build(whatHappened: String, stepsToFix: List, cause: Throwable?) = + MicrosoftAuthException(whatHappened, stepsToFix, cause) +} diff --git a/src/main/kotlin/org/polyfrost/polyplus/client/launcher/OneLauncherAccounts.kt b/src/main/kotlin/org/polyfrost/polyplus/client/launcher/OneLauncherAccounts.kt index 6334440..6929ae7 100644 --- a/src/main/kotlin/org/polyfrost/polyplus/client/launcher/OneLauncherAccounts.kt +++ b/src/main/kotlin/org/polyfrost/polyplus/client/launcher/OneLauncherAccounts.kt @@ -11,6 +11,7 @@ object OneLauncherAccounts { val username: String, val microsoft: Boolean, val active: Boolean, + val expired: Boolean, ) fun list(): List { @@ -20,15 +21,22 @@ object OneLauncherAccounts { return store.users.values.mapNotNull { stored -> val id = LauncherAccountStore.parseUuid(stored.id) ?: return@mapNotNull null + val microsoft = stored.kind.equals("microsoft", ignoreCase = true) Account( id = id, username = stored.username, - microsoft = stored.kind.equals("microsoft", ignoreCase = true), + microsoft = microsoft, active = id == defaultId, + expired = microsoft && isExpired(stored.expires), ) }.sortedWith(compareByDescending { it.active }.thenBy { it.username.lowercase() }) } + private fun isExpired(expires: String): Boolean { + val at = runCatching { java.time.Instant.parse(expires) }.getOrNull() ?: return true + return at.isBefore(java.time.Instant.now().plusSeconds(60)) + } + fun switchTo(id: UUID): Boolean { val store = LauncherAccountStore.load() val stored = store.users[id.toString()] @@ -51,12 +59,36 @@ object OneLauncherAccounts { return true } - suspend fun addMicrosoft(): Account { - val account = MicrosoftAuth.login() + suspend fun beginLogin(): MicrosoftAuth.MicrosoftLoginSession = MicrosoftAuth.beginLogin() + + suspend fun finishLogin(session: MicrosoftAuth.MicrosoftLoginSession): Account { + val account = MicrosoftAuth.finishLogin(session) commit(account, makeDefaultIfNone = true) return account.toAccount(active = false) } + fun cancelLogin(session: MicrosoftAuth.MicrosoftLoginSession) = MicrosoftAuth.cancelLogin(session) + + suspend fun refresh(id: UUID): Account { + val store = LauncherAccountStore.load() + val key = store.users.keys.firstOrNull { LauncherAccountStore.parseUuid(it) == id } + ?: error("Account not found") + val stored = store.users[key] ?: error("Account not found") + require(stored.kind.equals("microsoft", ignoreCase = true)) { + "Only Microsoft accounts can be refreshed" + } + + val refreshed = MicrosoftAuth.refreshAccount(stored) + + val current = LauncherAccountStore.load() + val users = current.users.toMutableMap().apply { put(key, refreshed) } + LauncherAccountStore.save(current.copy(users = users)) + + val isDefault = current.defaultUser == key + if (isDefault) AccountSwitch.apply(refreshed) + return refreshed.toAccount(active = isDefault) + } + fun addOffline(rawUsername: String): Account { val username = rawUsername.trim() val store = LauncherAccountStore.load() @@ -98,5 +130,6 @@ object OneLauncherAccounts { username = username, microsoft = kind.equals("microsoft", ignoreCase = true), active = active, + expired = kind.equals("microsoft", ignoreCase = true) && isExpired(expires), ) } diff --git a/src/main/resources/assets/polyplus/mainmenu/copy-01.svg b/src/main/resources/assets/polyplus/mainmenu/copy-01.svg new file mode 100644 index 0000000..bd55d6e --- /dev/null +++ b/src/main/resources/assets/polyplus/mainmenu/copy-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/main/resources/assets/polyplus/mainmenu/key-01.svg b/src/main/resources/assets/polyplus/mainmenu/key-01.svg new file mode 100644 index 0000000..65157a4 --- /dev/null +++ b/src/main/resources/assets/polyplus/mainmenu/key-01.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/assets/polyplus/mainmenu/link-external-01.svg b/src/main/resources/assets/polyplus/mainmenu/link-external-01.svg new file mode 100644 index 0000000..db60113 --- /dev/null +++ b/src/main/resources/assets/polyplus/mainmenu/link-external-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/main/resources/assets/polyplus/mainmenu/loading-02.svg b/src/main/resources/assets/polyplus/mainmenu/loading-02.svg new file mode 100644 index 0000000..60e6767 --- /dev/null +++ b/src/main/resources/assets/polyplus/mainmenu/loading-02.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/main/resources/assets/polyplus/mainmenu/refresh-cw-01.svg b/src/main/resources/assets/polyplus/mainmenu/refresh-cw-01.svg new file mode 100644 index 0000000..dde1891 --- /dev/null +++ b/src/main/resources/assets/polyplus/mainmenu/refresh-cw-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file From f5ed43a140711e35213af7b7bce5e1e43c5267eb Mon Sep 17 00:00:00 2001 From: awruff Date: Tue, 21 Jul 2026 21:37:55 -0400 Subject: [PATCH 4/4] 1.1.0 --- CHANGELOG.md | 5 +++-- gradle.properties | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cd2b14..3ea4a99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ -1.0.4 changelogs: +1.1.0 changelogs: -- fix cosmetics showing on invisible players... \ No newline at end of file +- adds account switcher functionality +- fixes player preview on 26.2 \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 13712b0..624d689 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,7 +12,7 @@ kotlinx.atomicfu.enableJvmIrTransformation=true # Mod metadata mod.name=PolyPlus mod.id=polyplus -mod.version=1.0.4 +mod.version=1.1.0 mod.group=org.polyfrost oneconfig_version=1.0.0