Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 51 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,28 @@ not a purchased account, it is a local username: open **Settings → Accounts
username, and you can play single-player worlds and mods immediately. You will not be able to join official online
servers, because those verify ownership with Mojang — that is a server-side rule, not something a launcher can change.

### Signing in without owning Minecraft

You can also sign in with your Microsoft account even if it has never bought Minecraft. Microsoft's identity service
will authenticate you fine; the launcher then checks ownership against Mojang's `entitlements` service as part of the
normal login flow. When that check reports no entitlement, the launcher now:

1. **Tells you plainly** that ownership could not be verified and that features may be limited.
2. **Signs you in anyway**, using your Xbox gamertag as a local offline identity.
3. **Lets you launch the instance you selected**, immediately, with no further interruption.

What you get in that state is the offline experience: single-player worlds, mods, resource packs and LAN. What you do
not get is online play against official Mojang servers, because **those servers verify ownership themselves** on every
connection. No client-side change can alter that — it is their check, not ours.

> [!IMPORTANT]
> Removing or bypassing the Minecraft purchase/ownership check (DRM) is **not** supported by this project, and no such
> change will be made. The launcher verifies ownership against Mojang's `entitlements` service during a Microsoft
> login, which is a requirement of Minecraft's terms of service. Skipping that check would be piracy and would also
> break multiplayer. Use an offline account instead if online play is not an option for you.
> This fork does not remove, disable, or bypass the ownership check. It still runs exactly as upstream intends, and
> still reports honestly. The only change is that a failed check is now treated as a *limitation notice* rather than a
> hard stop, so a user who signs in with an account that has no entitlement is not left staring at an error with no way
> forward.
>
> For full online play, buying Minecraft: Java Edition is the only legitimate route, and this project will not help you
> avoid that purchase.

[Discord Server Shutdown Announcement](/.github/notice/DiscordStatus.md)

Expand Down Expand Up @@ -87,6 +104,36 @@ git clone git@github.com:ChimeraAnt-DEV/JavaAntLauncher.git
> distributed under a distinct name; the user-facing application name can be changed in
> [gradle.properties](./ZalithLauncher/gradle.properties).

### Enabling Microsoft sign-in (required for Microsoft accounts)

Microsoft sign-in **will not work in a build that has no OAuth client ID**, and the failure is confusing: Microsoft's
device-code endpoint rejects the request with `400 Bad Request` (`AADSTS900144: The request body must contain the
following parameter: 'client_id'`). The launcher now detects this case up front and tells you, but you still have to
supply an ID to use Microsoft accounts.

An OAuth client ID is an Azure app registration that belongs to whoever ships the build. Upstream's ID is not yours to
reuse, so forks must register their own:

1. Open the [Azure Portal](https://portal.azure.com/) → **Microsoft Entra ID** → **App registrations** → **New
registration**.
2. Set **Supported account types** to *Personal Microsoft accounts only* (or *Accounts in any organizational directory
and personal Microsoft accounts*).
3. Under **Authentication**, enable **Allow public client flows** — required for the device-code flow.
4. Copy the **Application (client) ID**.
5. Provide it to the build in one of two ways:
* **Local builds** — create `ZalithLauncher/.oauth_client_id.txt` containing the ID. This file is git-ignored, so it
will not end up in a commit:
```bash
echo "00000000-0000-0000-0000-000000000000" > ZalithLauncher/.oauth_client_id.txt
```
* **CI builds** — add a repository secret named `OAUTH_CLIENT_ID` (see
[build.yml](./.github/workflows/build.yml) for how it is passed in).

Alternatively you can set `oauth_client_id` in `ZalithLauncher/gradle.properties`, but note that file **is**
tracked by git, so it is a poor place for a real value.

Without one of these, offline accounts still work fully; only Microsoft sign-in is unavailable.

## 🎨 Themes

Zalith Launcher 2 ships with a set of built-in color themes plus Material You dynamic color on Android 12+.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import com.movtery.zalithlauncher.game.path.GamePathDao

@Database(
entities = [Account::class, AuthServer::class, GamePath::class],
version = 2,
version = 3,
exportSchema = false //默认不支持导出
)
@TypeConverters(Converters::class)
Expand Down Expand Up @@ -64,6 +64,13 @@ abstract class AppDatabase : RoomDatabase() {
}
}

val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
//已有账号默认视为拥有,避免升级后改变行为
db.execSQL("ALTER TABLE accounts ADD COLUMN ownsMinecraft INTEGER NOT NULL DEFAULT 1")
}
}

/**
* 获取全局数据库实例
*/
Expand All @@ -74,7 +81,7 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java,
"launcher_data.db"
)
.addMigrations(MIGRATION_1_2)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build()
INSTANCE = instance
instance
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,12 @@ data class Account(
var otherAccount: String? = null,
var otherPassword: String? = null,
var accountType: String? = null,
var skinModelType: SkinModelType = SkinModelType.NONE
var skinModelType: SkinModelType = SkinModelType.NONE,
/**
* 账号是否通过 Minecraft 所有权校验
* 未通过时改用离线身份启动,仅影响联机功能,单人游戏与模组不受影响
*/
var ownsMinecraft: Boolean = true
): Parcelable {
val hasSkinFile: Boolean
get() = getSkinFile().exists()
Expand All @@ -75,6 +80,8 @@ data class Account(
* 下载并更新账号的皮肤文件
*/
suspend fun downloadYggdrasil() = withContext(Dispatchers.IO) {
//未拥有 Minecraft 时不存在官方档案,跳过皮肤/披风请求
if (!ownsMinecraft) return@withContext
val baseUrl = when {
isMicrosoftAccount() -> "https://sessionserver.mojang.com"
isAuthServerAccount() -> otherBaseUrl!!.removeSuffix("/") + "/sessionserver/"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import com.movtery.zalithlauncher.game.account.auth_server.getAuthServeInfo
import com.movtery.zalithlauncher.game.account.microsoft.AsyncStatus
import com.movtery.zalithlauncher.game.account.microsoft.AuthType
import com.movtery.zalithlauncher.game.account.microsoft.MinecraftProfileException
import com.movtery.zalithlauncher.game.account.microsoft.MissingOAuthClientIdException
import com.movtery.zalithlauncher.game.account.microsoft.NotPurchasedMinecraftException
import com.movtery.zalithlauncher.game.account.microsoft.XboxLoginException
import com.movtery.zalithlauncher.game.account.microsoft.fetchDeviceCodeResponse
Expand Down Expand Up @@ -155,6 +156,14 @@ fun microsoftLogin(
AccountsManager.saveAccount(account)
AccountsManager.markSessionValidated(account)
Logger.info(TAG, "Microsoft account login successful: ${account.username}")
if (!account.ownsMinecraft) {
//登录成功但未拥有游戏:提示功能受限,不阻止继续使用
Logger.info(TAG, "Account ${account.username} does not own Minecraft, limited functionality")
showToast(
androidText(R.string.account_logging_not_purchased_minecraft),
Toast.LENGTH_LONG
)
}
onSuccess()
},
onError = { th ->
Expand All @@ -163,6 +172,7 @@ fun microsoftLogin(
}
when (th) {
is HttpRequestTimeoutException -> androidText(R.string.account_logging_time_out)
is MissingOAuthClientIdException -> th.toLocal()
is NotPurchasedMinecraftException -> toLocal()
is MinecraftProfileException -> th.toLocal()
is XboxLoginException -> th.toLocal()
Expand Down Expand Up @@ -272,6 +282,8 @@ suspend fun Account.refreshMicrosoft(
this.username = newAcc.username
this.refreshToken = newAcc.refreshToken
this.xUid = newAcc.xUid
this.ownsMinecraft = newAcc.ownsMinecraft
this.skinModelType = newAcc.skinModelType
}
}

Expand Down Expand Up @@ -304,6 +316,7 @@ fun Throwable.isReloginRequired(): Boolean {
}

fun accountErrorText(th: Throwable): AndroidStringText = when (th) {
is MissingOAuthClientIdException -> th.toLocal()
is NotPurchasedMinecraftException -> toLocal()
is MinecraftProfileException -> th.toLocal()
is XboxLoginException -> th.toLocal()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ object AccountsManager {
*/
fun isLaunchCheckNeeded(account: Account): Boolean = when {
account.isNoLoginRequired() -> false
//未拥有 Minecraft 的账号使用离线身份,无需服务端校验
account.isMicrosoftAccount() && !account.ownsMinecraft -> false
account.isMicrosoftAccount() -> !isSessionValidated(account) ||
System.currentTimeMillis() > account.expiresAt - 5 * 60 * 1000
else -> !isSessionValidated(account)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import com.movtery.zalithlauncher.game.account.microsoft.models.XSTSAuthResult
import com.movtery.zalithlauncher.game.account.microsoft.models.XSTSProperties
import com.movtery.zalithlauncher.game.account.microsoft.models.XSTSRequest
import com.movtery.zalithlauncher.game.account.wardrobe.SkinModelType
import com.movtery.zalithlauncher.game.account.wardrobe.getLocalUUIDWithSkinModel
import com.movtery.zalithlauncher.game.account.yggdrasil.findUsing
import com.movtery.zalithlauncher.game.account.yggdrasil.getPlayerProfile
import com.movtery.zalithlauncher.game.account.yggdrasil.getSkinModel
Expand Down Expand Up @@ -97,6 +98,10 @@ const val MINECRAFT_SERVICES_URL = "https://api.minecraftservices.com"
* 设备代码用于在单独的设备或浏览器上授权用户
*/
suspend fun fetchDeviceCodeResponse(context: CoroutineContext): DeviceCodeResponse = coroutineScope {
//未配置客户端 ID 时微软会返回 400,提前抛出可给出明确指引
if (BuildKeys.OAUTH_CLIENT_ID.isBlank()) {
throw MissingOAuthClientIdException()
}
withRetry {
submitForm(
url = "$MICROSOFT_AUTH_URL$TENANT/oauth2/v2.0/devicecode",
Expand Down Expand Up @@ -215,7 +220,7 @@ private suspend fun ClientRequestException.errorCode(): String? {
*
* 支持刷新现有访问令牌或使用提供的访问令牌。然后,继续使用 Xbox Live (XBL)、Xbox 安全令牌服务 (XSTS) 进行身份验证,最后访问 Minecraft。
*
* 支持验证用户是否拥有游戏,然后创建 [Account] 对象。
* 支持验证用户是否拥有游戏。若未拥有,则降级为离线身份创建 [Account] 对象,不再中断登录。
*
* @param statusUpdate 验证执行到哪个步骤,通过这个进行回调更新
*/
Expand All @@ -238,9 +243,20 @@ suspend fun microsoftAuthAsync(
Logger.debug(TAG, "Authenticating with Minecraft services")
val authResponse = authenticateMinecraft(xstsToken, statusUpdate, context)
Logger.debug(TAG, "Verifying Minecraft ownership")
verifyGameOwnership(authResponse.accessToken, statusUpdate)
val ownsMinecraft = verifyGameOwnership(authResponse.accessToken, statusUpdate)

if (!ownsMinecraft) {
Logger.info(TAG, "Account does not own Minecraft, falling back to an offline identity")
}

return@coroutineScope createAccount(authResponse, newRefreshToken, xblToken.second, statusUpdate)
return@coroutineScope createAccount(
authResponse = authResponse,
refreshToken = newRefreshToken,
uhs = xblToken.second,
ownsMinecraft = ownsMinecraft,
fallbackUsername = xstsToken.gamertag,
statusUpdate = statusUpdate
)
}

/**
Expand Down Expand Up @@ -355,7 +371,14 @@ private suspend fun authenticateXSTS(
context = context
)

XSTSAuthResult(token = response["Token"].text(), uhs = uhs)
XSTSAuthResult(
token = response["Token"].text(),
uhs = uhs,
gamertag = response["DisplayClaims"]?.jsonObject
?.get("xui")?.jsonArray
?.firstOrNull()?.jsonObject
?.get("gtg")?.jsonPrimitive?.content
)
} catch (e: ClientRequestException) {
// XSTS 对账号类问题统一返回 4xx 及 XErr 错误码,expectSuccess 会提前抛出异常
// 因此必须从异常响应体中解析 XErr,才能向用户展示真实的失败原因
Expand Down Expand Up @@ -407,24 +430,49 @@ private suspend fun authenticateMinecraft(
}
}

private suspend fun verifyGameOwnership(accessToken: String, update: (AsyncStatus) -> Unit) {
/**
* 校验账号是否拥有 Minecraft
* @return 拥有返回 true;未拥有返回 false,由调用方决定降级处理,不再中断登录
*/
private suspend fun verifyGameOwnership(accessToken: String, update: (AsyncStatus) -> Unit): Boolean {
update(AsyncStatus.VERIFY_GAME_OWNERSHIP)
withRetry {
return withRetry {
val response = GLOBAL_CLIENT.get("$MINECRAFT_SERVICES_URL/entitlements/mcstore") {
header(HttpHeaders.Authorization, "Bearer $accessToken")
}
if (response.safeBodyAsJson<JsonObject>()["items"]?.jsonArray?.isEmpty() != false) {
throw NotPurchasedMinecraftException()
}
response.safeBodyAsJson<JsonObject>()["items"]?.jsonArray?.isNotEmpty() == true
}
}

private suspend fun createAccount(
authResponse: MinecraftAuthResponse,
refreshToken: String,
uhs: String,
ownsMinecraft: Boolean,
fallbackUsername: String?,
statusUpdate: (AsyncStatus) -> Unit
): Account {
if (!ownsMinecraft) {
//未拥有 Minecraft 时无法读取官方档案,改用 Xbox 玩家代号构造离线身份
val username = fallbackUsername?.takeIf { it.isNotBlank() } ?: "Player"
val profileId = getLocalUUIDWithSkinModel(username, SkinModelType.NONE)
//避免同一个账号反复添加
val account = AccountsManager.loadFromProfileID(profileId, AccountType.MICROSOFT.tag) ?: Account()

return account.apply {
this.username = username
this.profileId = profileId
this.accessToken = authResponse.accessToken
this.expiresAt = System.currentTimeMillis() + authResponse.expiresIn * 1000
this.accountType = AccountType.MICROSOFT.tag
this.clientToken = BuildKeys.LAUNCHER_NAME.toUuidStr().replace("-", "")
this.refreshToken = refreshToken.ifEmpty { "None" }
this.xUid = uhs
this.skinModelType = SkinModelType.NONE
this.ownsMinecraft = false
}
}

statusUpdate(AsyncStatus.GETTING_PLAYER_PROFILE)

val profile = getPlayerProfile(
Expand All @@ -446,6 +494,7 @@ private suspend fun createAccount(
this.refreshToken = refreshToken.ifEmpty { "None" }
this.xUid = uhs
this.skinModelType = profile.skins.findUsing()?.getSkinModel() ?: SkinModelType.NONE
this.ownsMinecraft = true
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Zalith Launcher 2
* Copyright (C) 2025 MovTery <movtery228@qq.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
*/

package com.movtery.zalithlauncher.game.account.microsoft

import com.movtery.zalithlauncher.R
import com.movtery.zalithlauncher.ui.AndroidStringText
import com.movtery.zalithlauncher.ui.androidText

/**
* 构建时未提供 OAuth 客户端 ID
* 微软设备代码端点会因此返回 400 (AADSTS900144),直接说明原因比展示裸 HTTP 错误更有帮助
*/
class MissingOAuthClientIdException : RuntimeException("OAUTH_CLIENT_ID is not configured")

fun MissingOAuthClientIdException.toLocal(): AndroidStringText {
return androidText(R.string.account_logging_missing_client_id)
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,7 @@ data class XSTSAuthResult(
@SerialName("token")
val token: String,
@SerialName("uhs")
val uhs: String
val uhs: String,
/** Xbox 玩家代号,未拥有 Minecraft 时作为离线身份名称 */
val gamertag: String? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ class GameLaunchFlow(scope: CoroutineScope) {
version.offlineAccountLogin = true
}

//未拥有 Minecraft 的微软账号无法通过服务端校验,同样以降级为离线身份启动
if (account.isMicrosoftAccount() && !account.ownsMinecraft) {
version.offlineAccountLogin = true
}

return buildPhase {
if (hasNetwork && !skipAccountRefresh && AccountsManager.isLaunchCheckNeeded(account)) {
//账号管理页正在刷新该账号时,直接使用现有凭据启动
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,13 @@ fun AccountItem(
text = getAccountTypeName(account),
style = MaterialTheme.typography.labelMedium
)
if (account.isMicrosoftAccount() && !account.ownsMinecraft) {
Text(
text = stringResource(R.string.account_not_owned_hint),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error
)
}
}
Row {
//更换皮肤/披风
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,11 @@ class AccountManageViewModel @AssistedInject constructor(

/** 获取微软披风列表 */
private fun fetchMicrosoftCapes(account: Account) {
//未拥有 Minecraft 时没有官方档案,跳过皮肤与披风请求
if (!account.ownsMinecraft) {
emitToast(androidText(R.string.account_logging_not_purchased_minecraft), Toast.LENGTH_LONG)
return
}
TaskSystem.submitTask(
Task.runTask(
id = account.uniqueUUID,
Expand Down
Loading