launcher: Play needs a Microsoft account that owns Minecraft: Java Edition
Like other launchers, the launcher now starts the game only while an account in it owns Java Edition (Ludovic's decision, 2026-10-27). - account::launch is the owner gate: it asks the Minecraft services again (entitlements and profile, after the token renewal), the selected account first, until one owns the game. Each answer is kept in the keychain item beside the tokens. No answer (no network) keeps the last one; "doesn't own" blocks at once. - Every Play goes through it: the window (Home, instances, packs), `horizon-launcher play` and `modpack play`, the MCP's play. - Offline names are added and played only beside an owner; signing the last owner out keeps them, unplayable. - Home shows "Sign in with a Microsoft account that owns Minecraft: Java Edition to play" with Sign in; the "coming soon" sign-in strings are gone. - The game binary, harnesses, CI and the servers are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,7 +31,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that re
|
||||
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
|
||||
|
||||
**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)); Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -47,7 +47,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining offline-mode servers. Horizon cannot sign in to a Microsoft account yet, so it cannot join servers that check accounts. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
|
||||
@@ -158,7 +158,7 @@ them too (Rust uses Java's own float maths on each platform).
|
||||
|
||||
### Known issues
|
||||
|
||||
Horizon can't sign in to a Microsoft account yet, so it can't join online-mode servers (it joins offline-mode ones); skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
|
||||
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ MCP: `prepare`.
|
||||
|
||||
## `horizon-launcher play`
|
||||
|
||||
Start the client.
|
||||
Start the client (needs a signed-in Microsoft account that owns Minecraft: Java Edition).
|
||||
|
||||
horizon-launcher play
|
||||
|
||||
@@ -98,7 +98,7 @@ MCP: `account_list`.
|
||||
|
||||
## `horizon-launcher account sign-in`
|
||||
|
||||
Sign in with a Microsoft account in your browser (device code).
|
||||
Sign in with a Microsoft account in your browser (device code). Play needs one that owns Minecraft: Java Edition.
|
||||
|
||||
horizon-launcher account sign-in
|
||||
|
||||
@@ -126,7 +126,7 @@ MCP: `account_select`.
|
||||
|
||||
## `horizon-launcher account offline`
|
||||
|
||||
Play offline, without an account: as NAME (kept to switch back to; 3 to 16 letters, digits or _), or as the name the game made up.
|
||||
Play offline (LAN, offline-mode servers) beside a signed-in account that owns the game: as NAME (kept to switch back to; 3 to 16 letters, digits or _), or as the name the game made up.
|
||||
|
||||
horizon-launcher account offline [NAME]
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
# Microsoft account sign-in and online-mode play
|
||||
|
||||
Horizon signs players in with their Microsoft account in the launcher and joins online-mode
|
||||
(authenticated) servers like vanilla 26.3 does. Offline profiles and offline/LAN play stay exactly as
|
||||
they are. This note is the design, the status of each part and what Ludovic has to register.
|
||||
(authenticated) servers like vanilla 26.3 does. The launcher plays only with an account that owns
|
||||
Minecraft: Java Edition ([the owner gate](#the-owner-gate)); offline names, LAN and offline-mode
|
||||
servers play behind it. This note is the design, the status of each part and what Ludovic has to
|
||||
register.
|
||||
|
||||
## What Ludovic registers (needs his accounts)
|
||||
|
||||
Nothing below can be done by an agent: the app registrations belong to Ludovic. Until the client ID
|
||||
is set, the launcher's sign-in shows "Coming soon" ("Microsoft sign-in is coming soon") and everything else
|
||||
works as before.
|
||||
Nothing below can be done by an agent: the app registrations belong to Ludovic. A build without
|
||||
the client ID can't sign in, so its launcher can't start the game ("This build of the launcher has
|
||||
no Microsoft sign-in"); release builds have it.
|
||||
|
||||
1. **Azure app registration** (https://portal.azure.com → Microsoft Entra ID → App registrations →
|
||||
New registration):
|
||||
@@ -64,14 +66,38 @@ All requests use HTTPS and JSON except the two OAuth calls (form-encoded):
|
||||
7. **Profile**: `GET https://api.minecraftservices.com/minecraft/profile`: UUID, name, skins, capes.
|
||||
404 = owns the game but has no profile yet ("Create your Minecraft profile on minecraft.net first").
|
||||
|
||||
## The owner gate
|
||||
|
||||
Decided 2026-10-27 (Ludovic, after a modder's suggestion): like other launchers, Horizon's
|
||||
launcher starts the game only while an account in it owns Java Edition. Offline play stays
|
||||
possible; the launcher just doesn't offer it on its own.
|
||||
|
||||
- **Play needs an owner.** Every Play (Home, instances and packs, `horizon-launcher play`,
|
||||
`modpack play`, the MCP's `play`) asks `account::launch` first. It asks the Minecraft services
|
||||
again for each account, the selected one first, until one owns the game: the token renewal
|
||||
(below) if due, then entitlements and profile. Without an owner the game doesn't start and Play
|
||||
says "Sign in with a Microsoft account that owns Minecraft: Java Edition to play", with Sign in.
|
||||
- **The answer lives with the tokens.** Each account's keychain item keeps the last answer
|
||||
(`owns`), so no plain file can say yes. With no network (or a sign-in that can't be renewed) the
|
||||
last answer stands: being offline never blocks a player who passed before, and the game starts
|
||||
without a session, offline. An answer of "doesn't own" (or no profile) blocks at once.
|
||||
- **Offline names play behind an owner.** They can be added and played only while an owning
|
||||
account is signed in. Signing the last one out keeps them, and they play again once one is back.
|
||||
- **Who isn't affected:** the game binary itself (`horizon-client`, `horizon-ctl launch`, the
|
||||
harnesses, CI), the dedicated server and `horizon-server`, the website. There is no bypass flag:
|
||||
the launcher's tests use accounts in a memory store (`MemoryStore`, and `HORIZON_NO_KEYCHAIN`
|
||||
for the CLI and MCP tests, which then have no owner).
|
||||
|
||||
Refresh: the Minecraft token is renewed from the Microsoft refresh token (steps 2-5 with
|
||||
`grant_type=refresh_token`) when it has less than an hour left, before every launch. A refused
|
||||
`grant_type=refresh_token`) when it has less than an hour left, before every launch; steps 6-7 run
|
||||
at every launch (the owner gate). A refused
|
||||
refresh token (`invalid_grant`) asks the player to sign in again. Several accounts can be signed in;
|
||||
one is selected. "Sign out" deletes the account's secrets from the keychain.
|
||||
|
||||
## Token storage
|
||||
|
||||
- Secrets (the Microsoft refresh token, the Minecraft access token and its expiry) go to the OS
|
||||
- Secrets (the Microsoft refresh token, the Minecraft access token and its expiry, the last
|
||||
ownership answer) go to the OS
|
||||
keychain, one item per account: Keychain on macOS, Credential Manager on Windows, the Secret
|
||||
Service (GNOME Keyring, KWallet) on Linux. Service name `dev.playhorizon.launcher`, account = the
|
||||
Minecraft UUID.
|
||||
|
||||
+12
-5
@@ -124,7 +124,9 @@ The opening, then Play ([clip](docs/opening.webm)):
|
||||
[instance] [--remove]`; `HORIZON_LAUNCHER_DESKTOP` points it at another folder.
|
||||
- **Accounts and quick switch**: the top bar's account chip opens a menu of everyone who can play:
|
||||
each signed-in Microsoft account, the offline names the player keeps, and the name the game
|
||||
made up (`horizon-player.txt`). Picking one plays as it from the next Play. Settings > Account
|
||||
made up (`horizon-player.txt`). Picking one plays as it from the next Play. Offline names play
|
||||
only while an account that owns the game is signed in ([Microsoft accounts](#microsoft-accounts));
|
||||
signing the last one out keeps them, unplayable until one is back. Settings > Account
|
||||
adds offline names (3 to 16 letters, digits or `_`; the game gets `--name`) and forgets them;
|
||||
the Console's Share log takes them out of the log too. `horizon-launcher account offline
|
||||
[NAME]`, `account select <name>` (an account or an offline name), `account forget-offline
|
||||
@@ -364,10 +366,15 @@ The window, the command line and the MCP server all call the same core, `src/bac
|
||||
|
||||
## Microsoft accounts
|
||||
|
||||
Settings → Accounts signs in a Microsoft account to play on online-mode servers under your own
|
||||
name and skin: the launcher shows a code, you enter it at microsoft.com/link, and the launcher
|
||||
checks that the account owns Minecraft: Java Edition. The Play page says who plays ("Playing as");
|
||||
without an account the game plays offline, as before. Tokens live in the system keychain (Keychain,
|
||||
The launcher starts the game only while a Microsoft account that owns Minecraft: Java Edition is
|
||||
signed in, as other launchers do: without one, Play (Home, instances, `horizon-launcher play`, the
|
||||
MCP's `play`) says "Sign in with a Microsoft account that owns Minecraft: Java Edition to play"
|
||||
and starts nothing. Settings → Accounts signs in: the launcher shows a code, you enter it at
|
||||
microsoft.com/link, and the launcher checks that the account owns the game. It asks again at every
|
||||
Play, with the token renewal; with no network the last answer stands, and a "doesn't own" blocks
|
||||
at once. The account also plays online-mode servers under your own name and skin; offline names
|
||||
play LAN and offline-mode servers beside it. The game binary started directly (`horizon-client`,
|
||||
`horizon-ctl launch`), the harnesses and the servers don't go through this check. Tokens live in the system keychain (Keychain,
|
||||
Credential Manager, or the Secret Service on Linux), never in a file, a log or the command line;
|
||||
`accounts.json` in the data folder keeps only names and UUIDs. Without a keychain you stay signed
|
||||
in until the launcher closes. When the game starts, it gets the session in a private file it
|
||||
|
||||
@@ -48,11 +48,8 @@
|
||||
"Applications menu": "Menu des applications",
|
||||
"Horizon and its icon are in your desktop's menu.": "Horizon et son icône sont dans le menu de votre bureau.",
|
||||
"Not in your desktop's menu.": "Pas dans le menu de votre bureau.",
|
||||
"Microsoft account (coming soon)": "Compte Microsoft (bientôt)",
|
||||
"Microsoft account": "Compte Microsoft",
|
||||
"Another account": "Un autre compte",
|
||||
"Microsoft sign-in is coming soon. Until then the game plays offline: single player, LAN and servers in offline mode work.": "La connexion Microsoft arrive bientôt. D'ici là, le jeu se joue hors ligne : solo, LAN et serveurs en mode hors ligne fonctionnent.",
|
||||
"Coming soon": "Bientôt",
|
||||
"Microsoft account · plays": "Compte Microsoft · joue",
|
||||
"Microsoft account · signed in": "Compte Microsoft · connecté",
|
||||
"Asking Microsoft…": "Demande à Microsoft…",
|
||||
@@ -165,5 +162,10 @@
|
||||
"Installing the nightly, {percent}%": "Installation de la Nightly, {percent} %",
|
||||
"The nightly could not be installed: {error}": "La Nightly n'a pas pu être installée : {error}",
|
||||
"{version} in the Nightly instance. Choose Horizon in the sidebar to play the release.": "{version} dans l'instance Nightly. Choisissez Horizon dans la barre latérale pour jouer la version publiée.",
|
||||
"No nightly build yet.": "Pas encore de version Nightly."
|
||||
"No nightly build yet.": "Pas encore de version Nightly.",
|
||||
"This build of the launcher has no Microsoft sign-in (HORIZON_MSA_CLIENT_ID), so it can't start the game.": "Cette version du launcher n'a pas de connexion Microsoft (HORIZON_MSA_CLIENT_ID) : elle ne peut pas lancer le jeu.",
|
||||
"Sign in with a Microsoft account that owns Minecraft: Java Edition to play": "Connectez-vous avec un compte Microsoft qui possède Minecraft: Java Edition pour jouer",
|
||||
"Horizon needs a Microsoft account that owns Minecraft: Java Edition, like other launchers. It also plays online servers with your name and skin.": "Horizon a besoin d'un compte Microsoft qui possède Minecraft: Java Edition, comme les autres launchers. Il permet aussi de jouer sur les serveurs en ligne avec votre nom et votre skin.",
|
||||
"Sign in": "Se connecter",
|
||||
"Sign in with Microsoft": "Se connecter avec Microsoft"
|
||||
}
|
||||
|
||||
@@ -48,11 +48,8 @@
|
||||
"Applications menu": "Меню приложений",
|
||||
"Horizon and its icon are in your desktop's menu.": "Horizon и его значок есть в меню рабочего стола.",
|
||||
"Not in your desktop's menu.": "Нет в меню рабочего стола.",
|
||||
"Microsoft account (coming soon)": "Аккаунт Microsoft (скоро)",
|
||||
"Microsoft account": "Аккаунт Microsoft",
|
||||
"Another account": "Другой аккаунт",
|
||||
"Microsoft sign-in is coming soon. Until then the game plays offline: single player, LAN and servers in offline mode work.": "Вход через Microsoft скоро появится. А пока игра работает офлайн: одиночная игра, LAN и серверы в офлайн-режиме.",
|
||||
"Coming soon": "Скоро",
|
||||
"Microsoft account · plays": "Аккаунт Microsoft · играет",
|
||||
"Microsoft account · signed in": "Аккаунт Microsoft · вход выполнен",
|
||||
"Asking Microsoft…": "Запрос к Microsoft…",
|
||||
@@ -165,5 +162,10 @@
|
||||
"Installing the nightly, {percent}%": "Установка ночной сборки, {percent}%",
|
||||
"The nightly could not be installed: {error}": "Не удалось установить ночную сборку: {error}",
|
||||
"{version} in the Nightly instance. Choose Horizon in the sidebar to play the release.": "{version} в сборке Nightly. Выберите Horizon на боковой панели, чтобы играть в релиз.",
|
||||
"No nightly build yet.": "Ночных сборок пока нет."
|
||||
"No nightly build yet.": "Ночных сборок пока нет.",
|
||||
"This build of the launcher has no Microsoft sign-in (HORIZON_MSA_CLIENT_ID), so it can't start the game.": "В этой сборке лаунчера нет входа через Microsoft (HORIZON_MSA_CLIENT_ID), поэтому она не может запустить игру.",
|
||||
"Sign in with a Microsoft account that owns Minecraft: Java Edition to play": "Чтобы играть, войдите в аккаунт Microsoft, у которого есть Minecraft: Java Edition",
|
||||
"Horizon needs a Microsoft account that owns Minecraft: Java Edition, like other launchers. It also plays online servers with your name and skin.": "Как и другим лаунчерам, Horizon нужен аккаунт Microsoft, у которого есть Minecraft: Java Edition. С ним вы также играете на онлайн-серверах под своим именем и со своим скином.",
|
||||
"Sign in": "Войти",
|
||||
"Sign in with Microsoft": "Войти через Microsoft"
|
||||
}
|
||||
|
||||
+224
-82
@@ -1,8 +1,13 @@
|
||||
//! Microsoft account sign-in (docs/microsoft-account.md): the OAuth device-code flow, then Xbox
|
||||
//! Live, XSTS and the Minecraft services, the game ownership check and the profile. Secrets (the
|
||||
//! Microsoft refresh token, the Minecraft access token) live in the OS keychain; accounts.json
|
||||
//! keeps only names and UUIDs. The game gets the session in a file it reads once
|
||||
//! (`--session-file`), never on its command line. Tokens never reach logs, errors or output.
|
||||
//! Microsoft refresh token, the Minecraft access token, the last ownership answer) live in the OS
|
||||
//! keychain; accounts.json keeps only names and UUIDs. The game gets the session in a file it
|
||||
//! reads once (`--session-file`), never on its command line. Tokens never reach logs, errors or
|
||||
//! output.
|
||||
//!
|
||||
//! The owner gate ([`launch`]): the launcher starts the game only while an account in it owns
|
||||
//! Minecraft: Java Edition, as other launchers do. Offline names play behind such an account.
|
||||
//! The game binary itself, its harnesses and the servers don't go through the launcher's gate.
|
||||
//!
|
||||
//! The Azure app (client ID) is Horizon's own and is configuration: `HORIZON_MSA_CLIENT_ID` at
|
||||
//! build time, or at run time to override it. Without one, sign-in says it is not configured.
|
||||
@@ -16,8 +21,15 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
pub const SERVICE: &str = "dev.playhorizon.launcher";
|
||||
const SCOPE: &str = "XboxLive.signin offline_access";
|
||||
pub const NOT_CONFIGURED: &str =
|
||||
"Microsoft sign-in is coming soon. Until then the game plays offline: single player, \
|
||||
LAN and servers in offline mode work.";
|
||||
"This build of the launcher has no Microsoft sign-in (HORIZON_MSA_CLIENT_ID), so it can't start the game.";
|
||||
/// What every Play says while no account in the launcher owns the game.
|
||||
pub const NEEDS_OWNER: &str =
|
||||
"Sign in with a Microsoft account that owns Minecraft: Java Edition to play";
|
||||
const DOES_NOT_OWN: &str = "This account doesn't own Minecraft: Java Edition.";
|
||||
const NO_PROFILE: &str =
|
||||
"This account has no Minecraft profile yet. Create one at minecraft.net, then sign in again.";
|
||||
const OTHER_PROFILE: &str =
|
||||
"The Microsoft account now has another Minecraft profile. Sign in again.";
|
||||
const SIGN_IN_AGAIN: &str = "Your Microsoft sign-in expired. Sign in again.";
|
||||
/// The Minecraft token is renewed when it has less than this left.
|
||||
const RENEW_BEFORE: u64 = 3600;
|
||||
@@ -393,7 +405,8 @@ pub struct Account {
|
||||
pub cape: Option<String>,
|
||||
}
|
||||
|
||||
fn profile(ep: &Endpoints, token: &str) -> Result<Account, String> {
|
||||
/// The profile; none when the account has none (it doesn't own the game, or never made one).
|
||||
fn profile(ep: &Endpoints, token: &str) -> Result<Option<Account>, String> {
|
||||
let (status, v) = send(
|
||||
bearer(
|
||||
request_to("GET", &format!("{}/minecraft/profile", ep.minecraft)),
|
||||
@@ -403,7 +416,7 @@ fn profile(ep: &Endpoints, token: &str) -> Result<Account, String> {
|
||||
"the Minecraft services",
|
||||
)?;
|
||||
if status == 404 {
|
||||
return Err("This account has no Minecraft profile yet. Create one at minecraft.net, then sign in again.".into());
|
||||
return Ok(None);
|
||||
}
|
||||
let (Some(uuid), Some(name)) = (str_field(&v, "id"), str_field(&v, "name")) else {
|
||||
return Err("Could not read the Minecraft profile.".into());
|
||||
@@ -417,7 +430,7 @@ fn profile(ep: &Endpoints, token: &str) -> Result<Account, String> {
|
||||
})
|
||||
.cloned()
|
||||
};
|
||||
Ok(Account {
|
||||
Ok(Some(Account {
|
||||
uuid: uuid.replace('-', "").to_lowercase(),
|
||||
name,
|
||||
skin: active("skins").and_then(|s| {
|
||||
@@ -429,13 +442,7 @@ fn profile(ep: &Endpoints, token: &str) -> Result<Account, String> {
|
||||
))
|
||||
}),
|
||||
cape: active("capes").and_then(|c| str_field(&c, "url")),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the signed-in account still owns Minecraft: Java Edition, asked again now: the
|
||||
/// launcher downloads the game's files only for an owner (sources.rs).
|
||||
pub fn owns_minecraft(ep: &Endpoints, secrets: &Secrets) -> Result<bool, String> {
|
||||
owns_game(ep, &secrets.minecraft_token)
|
||||
}))
|
||||
}
|
||||
|
||||
/// What the keychain keeps for one account.
|
||||
@@ -446,6 +453,14 @@ pub struct Secrets {
|
||||
minecraft_expires_at: u64,
|
||||
#[serde(default)]
|
||||
xuid: Option<String>,
|
||||
/// The last answer the Minecraft services gave: whether the account owns the game. It is
|
||||
/// kept here, beside the tokens, so that no plain file can say yes. Accounts saved before it
|
||||
/// existed passed the same check at sign-in, which refuses everyone else.
|
||||
#[serde(default = "owned_at_sign_in")]
|
||||
owns: bool,
|
||||
}
|
||||
fn owned_at_sign_in() -> bool {
|
||||
true
|
||||
}
|
||||
impl std::fmt::Debug for Secrets {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
@@ -457,39 +472,69 @@ impl std::fmt::Debug for Secrets {
|
||||
pub fn finish_sign_in(ep: &Endpoints, ms: MsTokens) -> Result<(Account, Secrets), String> {
|
||||
let mc = minecraft_login(ep, &ms.access)?;
|
||||
if !owns_game(ep, &mc.token)? {
|
||||
return Err("This account doesn't own Minecraft: Java Edition.".into());
|
||||
return Err(DOES_NOT_OWN.into());
|
||||
}
|
||||
let account = profile(ep, &mc.token)?;
|
||||
Ok((
|
||||
account,
|
||||
Secrets {
|
||||
refresh_token: ms.refresh,
|
||||
let account = profile(ep, &mc.token)?.ok_or(NO_PROFILE)?;
|
||||
Ok((account, Secrets::new(ms.refresh, mc)))
|
||||
}
|
||||
|
||||
impl Secrets {
|
||||
fn new(refresh_token: String, mc: Minecraft) -> Self {
|
||||
Self {
|
||||
refresh_token,
|
||||
minecraft_token: mc.token,
|
||||
minecraft_expires_at: mc.expires_at,
|
||||
xuid: mc.xuid,
|
||||
},
|
||||
))
|
||||
owns: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Minecraft token with at least an hour left, renewed through the refresh token if needed.
|
||||
/// The profile is read again on renewal (the name may have changed).
|
||||
pub fn fresh(
|
||||
ep: &Endpoints,
|
||||
client_id: &str,
|
||||
account: &Account,
|
||||
secrets: Secrets,
|
||||
) -> Result<(Account, Secrets), String> {
|
||||
if secrets.minecraft_expires_at > now() + RENEW_BEFORE {
|
||||
return Ok((account.clone(), secrets));
|
||||
/// What asking the Minecraft services again found for one account.
|
||||
enum Check {
|
||||
/// It owns the game: its profile as it is now (the name may have changed) and its tokens.
|
||||
Owns(Account, Secrets),
|
||||
/// The services said it doesn't own the game (or has no profile any more).
|
||||
DoesNot(Secrets),
|
||||
/// No answer (no network, a sign-in that can't be renewed): the last answer stands.
|
||||
NoAnswer(String, Secrets),
|
||||
}
|
||||
|
||||
/// Asks whether `account` owns the game, with a Minecraft token renewed through the refresh token
|
||||
/// first when it has less than an hour left. The secrets come back renewed whatever the answer.
|
||||
fn check(ep: &Endpoints, client_id: Option<&str>, account: &Account, secrets: Secrets) -> Check {
|
||||
let mut secrets = secrets;
|
||||
if secrets.minecraft_expires_at <= now() + RENEW_BEFORE {
|
||||
let renewed = client_id
|
||||
.ok_or_else(|| NOT_CONFIGURED.to_string())
|
||||
.and_then(|id| refresh_ms(ep, id, &secrets.refresh_token))
|
||||
.and_then(|ms| Ok((minecraft_login(ep, &ms.access)?, ms.refresh)));
|
||||
match renewed {
|
||||
Ok((mc, refresh)) => {
|
||||
secrets = Secrets {
|
||||
owns: secrets.owns,
|
||||
..Secrets::new(refresh, mc)
|
||||
}
|
||||
}
|
||||
Err(e) => return Check::NoAnswer(e, secrets),
|
||||
}
|
||||
}
|
||||
let ms = refresh_ms(ep, client_id, &secrets.refresh_token)?;
|
||||
let (renewed, secrets) = finish_sign_in(ep, ms)?;
|
||||
if renewed.uuid != account.uuid {
|
||||
return Err(
|
||||
"The Microsoft account now has another Minecraft profile. Sign in again.".into(),
|
||||
);
|
||||
let token = &secrets.minecraft_token;
|
||||
match owns_game(ep, token).and_then(|owns| Ok(if owns { profile(ep, token)? } else { None })) {
|
||||
Err(e) => Check::NoAnswer(e, secrets),
|
||||
Ok(None) => Check::DoesNot(Secrets {
|
||||
owns: false,
|
||||
..secrets
|
||||
}),
|
||||
Ok(Some(p)) if p.uuid != account.uuid => Check::NoAnswer(OTHER_PROFILE.into(), secrets),
|
||||
Ok(Some(p)) => Check::Owns(
|
||||
p,
|
||||
Secrets {
|
||||
owns: true,
|
||||
..secrets
|
||||
},
|
||||
),
|
||||
}
|
||||
Ok((renewed, secrets))
|
||||
}
|
||||
|
||||
// ----- The keychain --------------------------------------------------------------------------------
|
||||
@@ -615,11 +660,15 @@ pub fn store() -> &'static dyn SecretStore {
|
||||
static STORE: OnceLock<Box<dyn SecretStore>> = OnceLock::new();
|
||||
STORE
|
||||
.get_or_init(|| {
|
||||
#[cfg(any(
|
||||
target_os = "linux",
|
||||
target_os = "freebsd",
|
||||
target_os = "macos",
|
||||
target_os = "windows"
|
||||
// Unit tests never reach the player's keychain.
|
||||
#[cfg(all(
|
||||
not(test),
|
||||
any(
|
||||
target_os = "linux",
|
||||
target_os = "freebsd",
|
||||
target_os = "macos",
|
||||
target_os = "windows"
|
||||
)
|
||||
))]
|
||||
if std::env::var_os("HORIZON_NO_KEYCHAIN").is_none() && SystemStore.get("probe").is_ok()
|
||||
{
|
||||
@@ -661,6 +710,18 @@ pub enum Player {
|
||||
GameName,
|
||||
}
|
||||
|
||||
const OFFLINE_NEEDS_OWNER: &str =
|
||||
"Offline names need a Microsoft account that owns Minecraft: Java Edition in the launcher.";
|
||||
|
||||
/// The secrets an account keeps in `store`, if it still has them there.
|
||||
fn secrets_in(store: &dyn SecretStore, uuid: &str) -> Option<Secrets> {
|
||||
store
|
||||
.get(uuid)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
}
|
||||
|
||||
/// Whether `name` can be an offline name: 3 to 16 letters, digits or `_`, as account names are.
|
||||
pub fn valid_offline_name(name: &str) -> bool {
|
||||
(3..=16).contains(&name.chars().count())
|
||||
@@ -714,6 +775,14 @@ impl Accounts {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Whether an account here owned the game at its last check (the keychain's answer, no
|
||||
/// request): what Play and offline names need.
|
||||
pub fn has_owner(&self, store: &dyn SecretStore) -> bool {
|
||||
self.accounts
|
||||
.iter()
|
||||
.any(|a| secrets_in(store, &a.uuid).is_some_and(|s| s.owns))
|
||||
}
|
||||
|
||||
/// Who plays now.
|
||||
pub fn player(&self) -> Player {
|
||||
match (self.selected(), &self.offline_selected) {
|
||||
@@ -723,8 +792,9 @@ impl Accounts {
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays as `who`: an account, an offline name (kept), or the game's own name.
|
||||
pub fn play_as(&mut self, who: Player) -> Result<(), String> {
|
||||
/// Plays as `who`: an account, an offline name (kept, only beside an account that owns the
|
||||
/// game), or the game's own name.
|
||||
pub fn play_as(&mut self, store: &dyn SecretStore, who: Player) -> Result<(), String> {
|
||||
match who {
|
||||
Player::Microsoft(uuid) => {
|
||||
if !self.accounts.iter().any(|a| a.uuid == uuid) {
|
||||
@@ -733,6 +803,9 @@ impl Accounts {
|
||||
self.selected = Some(uuid);
|
||||
}
|
||||
Player::Offline(name) => {
|
||||
if !self.has_owner(store) {
|
||||
return Err(OFFLINE_NEEDS_OWNER.into());
|
||||
}
|
||||
let name = self.add_offline(&name)?;
|
||||
self.selected = None;
|
||||
self.offline_selected = Some(name);
|
||||
@@ -746,7 +819,7 @@ impl Accounts {
|
||||
}
|
||||
|
||||
/// Keeps an offline name (once, in any case); returns it as kept.
|
||||
pub fn add_offline(&mut self, name: &str) -> Result<String, String> {
|
||||
fn add_offline(&mut self, name: &str) -> Result<String, String> {
|
||||
let name = name.trim();
|
||||
if !valid_offline_name(name) {
|
||||
return Err(format!(
|
||||
@@ -802,7 +875,15 @@ impl Accounts {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign out: the secrets leave the keychain, the account the list.
|
||||
/// A renewed profile (its name or skin changed) in place of the old one; who plays stays.
|
||||
fn update(&mut self, account: Account) {
|
||||
if let Some(a) = self.accounts.iter_mut().find(|a| a.uuid == account.uuid) {
|
||||
*a = account;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign out: the secrets leave the keychain, the account the list. Offline names stay, but
|
||||
/// play only once an account that owns the game is back.
|
||||
pub fn remove(&mut self, store: &dyn SecretStore, uuid: &str) -> Result<(), String> {
|
||||
store.delete(uuid)?;
|
||||
self.accounts.retain(|a| a.uuid != uuid);
|
||||
@@ -813,32 +894,88 @@ impl Accounts {
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected account with a token good for this game, renewed and saved if needed.
|
||||
pub fn session_for_launch(
|
||||
/// No account in the launcher owns Minecraft: Java Edition: the game must not start.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct NoOwner;
|
||||
impl std::fmt::Display for NoOwner {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{NEEDS_OWNER}.")
|
||||
}
|
||||
}
|
||||
|
||||
/// The owner gate's pass for one launch.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Launch {
|
||||
/// The selected account and its renewed session; none when an offline name plays, or when
|
||||
/// its sign-in couldn't be renewed now (`offline` says why).
|
||||
pub session: Option<(Account, Secrets)>,
|
||||
/// Why the selected account plays without its session this time.
|
||||
pub offline: Option<String>,
|
||||
/// An account the services said owns the game just now, the selected one first: who the
|
||||
/// launcher may download the game for.
|
||||
pub owner: Option<(Account, Secrets)>,
|
||||
}
|
||||
|
||||
/// The owner gate: the game starts only while an account in the launcher owns Minecraft: Java
|
||||
/// Edition. The accounts are asked again, the selected one first, until one owns it; each keeps
|
||||
/// the answer in the keychain. An account that gets no answer (no network) keeps its last one, so
|
||||
/// being offline never blocks a player who passed before, while "doesn't own" blocks at once.
|
||||
/// The caller saves `accounts` (renewed names and skins).
|
||||
pub fn launch(
|
||||
ep: &Endpoints,
|
||||
client_id: Option<&str>,
|
||||
store: &dyn SecretStore,
|
||||
accounts: &mut Accounts,
|
||||
) -> Result<Option<(Account, Secrets)>, String> {
|
||||
let Some(account) = accounts.selected().cloned() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let secrets: Secrets = store
|
||||
.get(&account.uuid)?
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"{} is no longer signed in on this computer. Sign in again.",
|
||||
account.name
|
||||
)
|
||||
})?;
|
||||
let client_id = client_id().ok_or(NOT_CONFIGURED)?;
|
||||
let before = secrets.minecraft_token.clone();
|
||||
let (renewed, secrets) = fresh(ep, &client_id, &account, secrets)?;
|
||||
if renewed != account || secrets.minecraft_token != before {
|
||||
accounts.add(store, renewed.clone(), &secrets)?;
|
||||
accounts.save()?;
|
||||
) -> Result<Launch, NoOwner> {
|
||||
let selected = accounts.selected.clone();
|
||||
let mut order = accounts.accounts.clone();
|
||||
order.sort_by_key(|a| Some(&a.uuid) != selected.as_ref());
|
||||
let mut pass = Launch::default();
|
||||
let mut owned = false;
|
||||
for account in order {
|
||||
if owned {
|
||||
break;
|
||||
}
|
||||
let plays = Some(&account.uuid) == selected.as_ref();
|
||||
let Some(secrets) = secrets_in(store, &account.uuid) else {
|
||||
if plays {
|
||||
pass.offline = Some(format!(
|
||||
"{} is no longer signed in on this computer. Sign in again.",
|
||||
account.name
|
||||
));
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let before = serde_json::to_string(&secrets).ok();
|
||||
let (said, secrets) = match check(ep, client_id, &account, secrets) {
|
||||
Check::Owns(renewed, secrets) => {
|
||||
accounts.update(renewed.clone());
|
||||
if plays {
|
||||
pass.session = Some((renewed.clone(), secrets.clone()));
|
||||
}
|
||||
pass.owner = Some((renewed, secrets.clone()));
|
||||
(None, secrets)
|
||||
}
|
||||
Check::DoesNot(secrets) => (Some(DOES_NOT_OWN.to_string()), secrets),
|
||||
Check::NoAnswer(e, secrets) => (Some(e), secrets),
|
||||
};
|
||||
owned = secrets.owns;
|
||||
if plays {
|
||||
pass.offline = said;
|
||||
}
|
||||
let after = serde_json::to_string(&secrets).ok();
|
||||
if after != before {
|
||||
if let Some(text) = after {
|
||||
// A store that refuses costs only this answer: the next launch asks again.
|
||||
let _ = store.set(&account.uuid, &text);
|
||||
}
|
||||
}
|
||||
}
|
||||
if owned {
|
||||
Ok(pass)
|
||||
} else {
|
||||
Err(NoOwner)
|
||||
}
|
||||
Ok(Some((renewed, secrets)))
|
||||
}
|
||||
|
||||
/// Write the session for the game, readable by this user only, in a fresh file it deletes
|
||||
@@ -933,19 +1070,24 @@ impl SignIn {
|
||||
}
|
||||
}
|
||||
|
||||
/// Give the game the selected account's session, when there is one.
|
||||
pub fn attach_session(
|
||||
cmd: &mut std::process::Command,
|
||||
game_home: &Path,
|
||||
) -> Result<Option<Account>, String> {
|
||||
/// The owner gate for the game about to start, then the selected account's session in a file
|
||||
/// for it: the file (none: offline) and why the account plays offline this time, if it does.
|
||||
pub fn session_for_game(game_home: &Path) -> Result<(Option<PathBuf>, Option<String>), NoOwner> {
|
||||
let mut accounts = Accounts::load();
|
||||
let Some((account, secrets)) = session_for_launch(&Endpoints::live(), store(), &mut accounts)?
|
||||
else {
|
||||
return Ok(None);
|
||||
let pass = launch(
|
||||
&Endpoints::live(),
|
||||
client_id().as_deref(),
|
||||
store(),
|
||||
&mut accounts,
|
||||
)?;
|
||||
let _ = accounts.save();
|
||||
let Some((account, secrets)) = pass.session else {
|
||||
return Ok((None, pass.offline));
|
||||
};
|
||||
let file = write_session_file(&game_home.join("sessions"), &account, &secrets)?;
|
||||
cmd.arg("--session-file").arg(file);
|
||||
Ok(Some(account))
|
||||
match write_session_file(&game_home.join("sessions"), &account, &secrets) {
|
||||
Ok(file) => Ok((Some(file), None)),
|
||||
Err(e) => Ok((None, Some(e))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+182
-37
@@ -233,29 +233,58 @@ fn the_whole_chain() {
|
||||
assert!(!format!("{secrets:?}").contains("mc-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_only_when_the_token_runs_out() {
|
||||
let (m, ep) = mock(|_| {});
|
||||
let account = Account {
|
||||
fn notch() -> Account {
|
||||
Account {
|
||||
uuid: UUID.into(),
|
||||
name: "Notch".into(),
|
||||
skin: None,
|
||||
cape: None,
|
||||
};
|
||||
let good = Secrets {
|
||||
}
|
||||
}
|
||||
|
||||
/// A signed-in account's secrets: a token good for two hours, and the last ownership answer.
|
||||
fn secrets(token: &str, owns: bool) -> Secrets {
|
||||
Secrets {
|
||||
refresh_token: "ms-refresh-1".into(),
|
||||
minecraft_token: "old".into(),
|
||||
minecraft_token: token.into(),
|
||||
minecraft_expires_at: now() + 7200,
|
||||
xuid: None,
|
||||
owns,
|
||||
}
|
||||
}
|
||||
|
||||
/// Nothing listens there: every request fails as with no network.
|
||||
fn offline() -> Endpoints {
|
||||
Endpoints {
|
||||
microsoft: "http://127.0.0.1:9/ms".into(),
|
||||
xbox_user: "http://127.0.0.1:9/xbl".into(),
|
||||
xsts: "http://127.0.0.1:9/xsts".into(),
|
||||
minecraft: "http://127.0.0.1:9/mc".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_only_when_the_token_runs_out() {
|
||||
let (m, ep) = mock(|_| {});
|
||||
let Check::Owns(_, kept) = check(
|
||||
&ep,
|
||||
Some("test-client"),
|
||||
¬ch(),
|
||||
secrets("mc-token", true),
|
||||
) else {
|
||||
panic!("owns");
|
||||
};
|
||||
let (_, kept) = fresh(&ep, "test-client", &account, good).unwrap();
|
||||
assert_eq!(kept.minecraft_token, "old");
|
||||
assert!(m.lock().unwrap().log.is_empty());
|
||||
assert_eq!(kept.refresh_token, "ms-refresh-1");
|
||||
// Entitlements and profile only: the token had two hours left.
|
||||
assert_eq!(m.lock().unwrap().log.len(), 2);
|
||||
let stale = Secrets {
|
||||
minecraft_expires_at: now() + 60,
|
||||
..kept.clone()
|
||||
};
|
||||
let (renewed_account, renewed) = fresh(&ep, "test-client", &account, stale).unwrap();
|
||||
let Check::Owns(renewed_account, renewed) = check(&ep, Some("test-client"), ¬ch(), stale)
|
||||
else {
|
||||
panic!("owns");
|
||||
};
|
||||
assert_eq!(
|
||||
(
|
||||
renewed.refresh_token.as_str(),
|
||||
@@ -269,25 +298,139 @@ fn refresh_only_when_the_token_runs_out() {
|
||||
minecraft_expires_at: 0,
|
||||
..kept
|
||||
};
|
||||
assert!(matches!(
|
||||
check(&ep, Some("test-client"), ¬ch(), revoked),
|
||||
Check::NoAnswer(e, s) if e == SIGN_IN_AGAIN && s.owns
|
||||
));
|
||||
}
|
||||
|
||||
/// An accounts.json with Notch signed in and playing, his secrets in `store`.
|
||||
fn signed_in(store: &MemoryStore, secrets: &Secrets) -> Accounts {
|
||||
let mut accounts = Accounts::default();
|
||||
accounts.add(store, notch(), secrets).unwrap();
|
||||
accounts
|
||||
}
|
||||
|
||||
fn kept(store: &MemoryStore) -> Secrets {
|
||||
secrets_in(store, UUID).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_owner_plays_with_a_session() {
|
||||
let (_, ep) = mock(|_| {});
|
||||
let store = MemoryStore::default();
|
||||
let mut accounts = signed_in(&store, &secrets("mc-token", true));
|
||||
let pass = launch(&ep, Some("test-client"), &store, &mut accounts).unwrap();
|
||||
let (account, session) = pass.session.unwrap();
|
||||
assert_eq!(
|
||||
fresh(&ep, "test-client", &account, revoked).unwrap_err(),
|
||||
SIGN_IN_AGAIN
|
||||
(account.name.as_str(), session.minecraft_token.as_str()),
|
||||
("Notch", "mc-token")
|
||||
);
|
||||
assert!(pass.owner.is_some() && pass.offline.is_none());
|
||||
// The profile as the services have it now (the skin is new).
|
||||
assert!(accounts.selected().unwrap().skin.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_from_the_services_blocks_at_once() {
|
||||
let (_, ep) = mock(|m| m.owns_game = false);
|
||||
let store = MemoryStore::default();
|
||||
let mut accounts = signed_in(&store, &secrets("mc-token", true));
|
||||
assert!(accounts.has_owner(&store));
|
||||
assert_eq!(
|
||||
launch(&ep, Some("test-client"), &store, &mut accounts).unwrap_err(),
|
||||
NoOwner
|
||||
);
|
||||
// The answer is kept beside the tokens: offline now, it still says no.
|
||||
assert!(!kept(&store).owns && !accounts.has_owner(&store));
|
||||
assert_eq!(
|
||||
launch(&offline(), Some("test-client"), &store, &mut accounts).unwrap_err(),
|
||||
NoOwner
|
||||
);
|
||||
assert_eq!(
|
||||
NoOwner.to_string(),
|
||||
"Sign in with a Microsoft account that owns Minecraft: Java Edition to play."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ownership_is_asked_again_with_the_saved_token() {
|
||||
let secrets = |token: &str| Secrets {
|
||||
refresh_token: "ms-refresh-1".into(),
|
||||
minecraft_token: token.into(),
|
||||
minecraft_expires_at: now() + 7200,
|
||||
xuid: None,
|
||||
fn no_network_never_blocks_a_past_owner() {
|
||||
let store = MemoryStore::default();
|
||||
let mut accounts = signed_in(&store, &secrets("mc-token", true));
|
||||
let pass = launch(&offline(), Some("test-client"), &store, &mut accounts).unwrap();
|
||||
// Plays, offline, and says why.
|
||||
assert!(pass.session.is_none() && pass.owner.is_none());
|
||||
assert!(pass.offline.unwrap().contains("Could not reach"));
|
||||
// A token to renew with no network, or a build without sign-in: the same.
|
||||
let stale = Secrets {
|
||||
minecraft_expires_at: 0,
|
||||
..secrets("mc-token", true)
|
||||
};
|
||||
let mut accounts = signed_in(&store, &stale);
|
||||
assert!(launch(&offline(), Some("test-client"), &store, &mut accounts).is_ok());
|
||||
assert!(launch(&offline(), None, &store, &mut accounts).is_ok());
|
||||
// An account that never passed doesn't pass offline.
|
||||
let mut accounts = signed_in(&store, &secrets("mc-token", false));
|
||||
assert!(launch(&offline(), Some("test-client"), &store, &mut accounts).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounts_from_before_the_gate_owned_the_game_at_sign_in() {
|
||||
let old = r#"{"refresh_token":"r","minecraft_token":"t","minecraft_expires_at":1,"xuid":null}"#;
|
||||
assert!(serde_json::from_str::<Secrets>(old).unwrap().owns);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_owner_opens_the_gate_and_the_one_who_plays_brings_the_session() {
|
||||
let (m, ep) = mock(|_| {});
|
||||
assert_eq!(owns_minecraft(&ep, &secrets("mc-token")), Ok(true));
|
||||
assert_eq!(m.lock().unwrap().log.len(), 1);
|
||||
let (_, ep) = mock(|m| m.owns_game = false);
|
||||
assert_eq!(owns_minecraft(&ep, &secrets("mc-token")), Ok(false));
|
||||
let store = MemoryStore::default();
|
||||
let mut accounts = signed_in(&store, &secrets("mc-token", true));
|
||||
// A second account, no longer in the keychain, plays.
|
||||
accounts.accounts.push(Account {
|
||||
uuid: "other".into(),
|
||||
name: "Alex".into(),
|
||||
skin: None,
|
||||
cape: None,
|
||||
});
|
||||
accounts.selected = Some("other".into());
|
||||
let pass = launch(&ep, Some("test-client"), &store, &mut accounts).unwrap();
|
||||
assert!(pass.session.is_none());
|
||||
assert!(pass
|
||||
.offline
|
||||
.unwrap()
|
||||
.starts_with("Alex is no longer signed in"));
|
||||
assert_eq!(pass.owner.unwrap().0.name, "Notch");
|
||||
// An offline name plays: Notch is asked, and no session goes to the game.
|
||||
accounts.selected = None;
|
||||
m.lock().unwrap().log.clear();
|
||||
let pass = launch(&ep, Some("test-client"), &store, &mut accounts).unwrap();
|
||||
assert!(pass.session.is_none() && pass.offline.is_none());
|
||||
assert_eq!(m.lock().unwrap().log.len(), 2);
|
||||
// No account at all.
|
||||
assert!(launch(&ep, Some("test-client"), &store, &mut Accounts::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_names_play_only_beside_an_owner() {
|
||||
let store = MemoryStore::default();
|
||||
let mut accounts = Accounts::default();
|
||||
let refused = accounts.play_as(&store, Player::Offline("Builder_1".into()));
|
||||
assert_eq!(refused.unwrap_err(), OFFLINE_NEEDS_OWNER);
|
||||
assert!(accounts.offline.is_empty());
|
||||
let mut accounts = signed_in(&store, &secrets("mc-token", true));
|
||||
accounts
|
||||
.play_as(&store, Player::Offline("Builder_1".into()))
|
||||
.unwrap();
|
||||
// Signing the last owner out keeps the name, which no longer plays.
|
||||
accounts.remove(&store, UUID).unwrap();
|
||||
assert_eq!(accounts.offline, ["Builder_1"]);
|
||||
assert_eq!(accounts.player(), Player::Offline("Builder_1".into()));
|
||||
assert!(!accounts.has_owner(&store));
|
||||
let (_, ep) = mock(|_| {});
|
||||
assert!(launch(&ep, Some("test-client"), &store, &mut accounts).is_err());
|
||||
assert!(accounts
|
||||
.play_as(&store, Player::Offline("Builder_2".into()))
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -305,10 +448,7 @@ fn refusals_have_messages() {
|
||||
.unwrap_err()
|
||||
.contains("child account"));
|
||||
let (_, ep) = mock(|m| m.owns_game = false);
|
||||
assert_eq!(
|
||||
finish_sign_in(&ep, tokens()).unwrap_err(),
|
||||
"This account doesn't own Minecraft: Java Edition."
|
||||
);
|
||||
assert_eq!(finish_sign_in(&ep, tokens()).unwrap_err(), DOES_NOT_OWN);
|
||||
let (_, ep) = mock(|m| m.no_profile = true);
|
||||
assert!(finish_sign_in(&ep, tokens())
|
||||
.unwrap_err()
|
||||
@@ -318,12 +458,8 @@ fn refusals_have_messages() {
|
||||
"Sign-in was declined in the browser."
|
||||
);
|
||||
// Nothing listening: a message, not a URL.
|
||||
let dead = Endpoints {
|
||||
microsoft: "http://127.0.0.1:9/ms".into(),
|
||||
..Endpoints::live()
|
||||
};
|
||||
assert_eq!(
|
||||
device_code(&dead, "x").unwrap_err(),
|
||||
device_code(&offline(), "x").unwrap_err(),
|
||||
"Could not reach Microsoft. Check your internet connection."
|
||||
);
|
||||
}
|
||||
@@ -353,6 +489,7 @@ fn accounts_keep_secrets_in_the_store_only() {
|
||||
minecraft_token: "mc-secret".into(),
|
||||
minecraft_expires_at: 1,
|
||||
xuid: None,
|
||||
owns: true,
|
||||
};
|
||||
accounts.add(&store, account.clone(), &secrets).unwrap();
|
||||
assert_eq!(accounts.selected(), Some(&account));
|
||||
@@ -386,6 +523,7 @@ fn session_files_are_private_and_complete() {
|
||||
minecraft_token: "mc-token".into(),
|
||||
minecraft_expires_at: 1,
|
||||
xuid: Some("25".into()),
|
||||
owns: true,
|
||||
};
|
||||
let path = write_session_file(&dir, &account, &secrets).unwrap();
|
||||
let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
@@ -426,6 +564,10 @@ fn client_id_is_configuration() {
|
||||
|
||||
#[test]
|
||||
fn offline_names_switch_with_accounts() {
|
||||
let store = MemoryStore::default();
|
||||
store
|
||||
.set("abc", &serde_json::to_string(&secrets("t", true)).unwrap())
|
||||
.unwrap();
|
||||
let mut a = Accounts {
|
||||
accounts: vec![Account {
|
||||
uuid: "abc".into(),
|
||||
@@ -438,20 +580,23 @@ fn offline_names_switch_with_accounts() {
|
||||
};
|
||||
assert_eq!(a.player(), Player::Microsoft("abc".into()));
|
||||
assert_eq!(a.name_arg(), None);
|
||||
a.play_as(Player::Offline("Builder_1".into())).unwrap();
|
||||
a.play_as(&store, Player::Offline("Builder_1".into()))
|
||||
.unwrap();
|
||||
assert_eq!(a.player(), Player::Offline("Builder_1".into()));
|
||||
assert_eq!(a.name_arg(), Some("Builder_1"));
|
||||
// Kept once, as first written, whatever the case.
|
||||
a.play_as(Player::Offline("builder_1".into())).unwrap();
|
||||
a.play_as(&store, Player::Offline("builder_1".into()))
|
||||
.unwrap();
|
||||
assert_eq!(a.offline, ["Builder_1"]);
|
||||
assert_eq!(a.name_arg(), Some("Builder_1"));
|
||||
assert!(a.add_offline("no spaces").is_err());
|
||||
assert!(a.add_offline("ab").is_err());
|
||||
assert!(a.add_offline("seventeen_letters").is_err());
|
||||
a.play_as(Player::Microsoft("abc".into())).unwrap();
|
||||
a.play_as(&store, Player::Microsoft("abc".into())).unwrap();
|
||||
assert_eq!(a.name_arg(), None, "an account plays with its own name");
|
||||
assert!(a.play_as(Player::Microsoft("zzz".into())).is_err());
|
||||
a.play_as(Player::Offline("Builder_1".into())).unwrap();
|
||||
assert!(a.play_as(&store, Player::Microsoft("zzz".into())).is_err());
|
||||
a.play_as(&store, Player::Offline("Builder_1".into()))
|
||||
.unwrap();
|
||||
a.remove_offline("BUILDER_1").unwrap();
|
||||
assert_eq!(a.player(), Player::GameName);
|
||||
assert!(a.remove_offline("Builder_1").is_err());
|
||||
|
||||
+39
-26
@@ -1195,7 +1195,7 @@ fn preparer_binary(dir: &Path) -> Option<PathBuf> {
|
||||
binary.is_file().then_some(binary)
|
||||
}
|
||||
|
||||
/// Downloads the pinned version from Mojang into `dir` when the signed-in account owns
|
||||
/// Downloads the pinned version from Mojang into `dir` when an account in the launcher owns
|
||||
/// Minecraft (asked again now), else says what signing in would do, as the last line of the
|
||||
/// not-found error.
|
||||
fn download_for_owner(
|
||||
@@ -1205,17 +1205,20 @@ fn download_for_owner(
|
||||
log: &dyn Fn(String),
|
||||
) -> Result<(), String> {
|
||||
let v = &pins.version;
|
||||
let owner = (|| -> Result<Option<(String, bool)>, String> {
|
||||
let ep = account::Endpoints::live();
|
||||
let mut accounts = account::Accounts::load();
|
||||
let Some((a, secrets)) = account::session_for_launch(&ep, account::store(), &mut accounts)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some((a.name, account::owns_minecraft(&ep, &secrets)?)))
|
||||
})();
|
||||
match owner {
|
||||
Ok(Some((name, true))) => {
|
||||
let mut accounts = account::Accounts::load();
|
||||
let pass = account::launch(
|
||||
&account::Endpoints::live(),
|
||||
account::client_id().as_deref(),
|
||||
account::store(),
|
||||
&mut accounts,
|
||||
);
|
||||
let _ = accounts.save();
|
||||
match pass {
|
||||
Ok(account::Launch {
|
||||
owner: Some((owner, _)),
|
||||
..
|
||||
}) => {
|
||||
let name = owner.name;
|
||||
log(format!(
|
||||
"{name} owns Minecraft: downloading {v} from Mojang"
|
||||
));
|
||||
@@ -1228,18 +1231,20 @@ fn download_for_owner(
|
||||
format!("\n • Horizon tried to download {v} from Mojang for {name}, but: {e}")
|
||||
})
|
||||
}
|
||||
Ok(Some((name, false))) => Err(format!(
|
||||
"\n • {name}, the signed-in account, doesn't own Minecraft: Java Edition, so Horizon \
|
||||
can't download it."
|
||||
// An owner by its last answer, who can't be asked now.
|
||||
Ok(pass) => Err(format!(
|
||||
"\n • Downloading {v} from Mojang needs your Microsoft account, which could not be \
|
||||
checked: {}",
|
||||
pass.offline.unwrap_or_default()
|
||||
)),
|
||||
Ok(None) => Err(format!(
|
||||
Err(_) if !accounts.accounts.is_empty() => Err(
|
||||
"\n • No signed-in account owns Minecraft: Java Edition, so Horizon can't download it."
|
||||
.into(),
|
||||
),
|
||||
Err(_) => Err(format!(
|
||||
"\n • Or sign in with the Microsoft account that owns Minecraft: Horizon then \
|
||||
downloads {v} from Mojang itself."
|
||||
)),
|
||||
Err(e) => Err(format!(
|
||||
"\n • Downloading {v} from Mojang needs your Microsoft account, which could not be \
|
||||
checked: {e}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1886,14 +1891,20 @@ pub fn need_prepared(tag: &str) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The client's command, once its resources are prepared.
|
||||
/// The client's command, once the owner gate lets it start and its resources are prepared. Past
|
||||
/// the gate, a sign-in that can't be renewed (no network, expired) still lets the game start,
|
||||
/// offline.
|
||||
pub fn client(tag: &str) -> Result<Command, String> {
|
||||
let (session, offline) = account::session_for_game(&game_home())
|
||||
.map_err(|refused| format!("{refused} Sign in with `horizon-launcher account sign-in`."))?;
|
||||
need_prepared(tag)?;
|
||||
complete_assets(tag);
|
||||
crate::browser::horizon::before_play(&game_home());
|
||||
let mut cmd = client_command(tag);
|
||||
// A sign-in that can't be renewed (no network, expired) still lets the game start, offline.
|
||||
if let Err(e) = crate::account::attach_session(&mut cmd, &game_home()) {
|
||||
if let Some(file) = session {
|
||||
cmd.arg("--session-file").arg(file);
|
||||
}
|
||||
if let Some(e) = offline {
|
||||
eprintln!("Playing offline: {e}");
|
||||
}
|
||||
append_game_args(&mut cmd);
|
||||
@@ -2441,9 +2452,11 @@ pub enum Msg {
|
||||
Reused(Vec<(String, String)>),
|
||||
/// Whether secrets are kept in the OS keychain (else only until the launcher closes).
|
||||
Keychain(bool),
|
||||
/// The selected account's session file for the game about to start (none = offline), or
|
||||
/// why it could not be renewed.
|
||||
Session(Result<Option<PathBuf>, String>),
|
||||
/// Whether an account in the launcher owned the game at its last check (the owner gate).
|
||||
Owner(bool),
|
||||
/// The owner gate's answer for the game about to start: the selected account's session
|
||||
/// file (none = offline) and why it plays offline, if it does.
|
||||
Session(Result<(Option<PathBuf>, Option<String>), crate::account::NoOwner>),
|
||||
/// A modpack step's line (`modpacks`).
|
||||
PackLine(String),
|
||||
/// A modpack step ended: the pack as it now is.
|
||||
|
||||
+16
-7
@@ -79,7 +79,8 @@ enum ServersCmd {
|
||||
enum AccountCmd {
|
||||
/// The signed-in accounts and which one plays
|
||||
List,
|
||||
/// Sign in with a Microsoft account in your browser (device code)
|
||||
/// Sign in with a Microsoft account in your browser (device code). Play needs one that owns
|
||||
/// Minecraft: Java Edition
|
||||
SignIn,
|
||||
/// Sign out: the account's tokens leave the keychain
|
||||
SignOut {
|
||||
@@ -88,8 +89,9 @@ enum AccountCmd {
|
||||
},
|
||||
/// Play as this account, or as one of your offline names
|
||||
Select { who: String },
|
||||
/// Play offline, without an account: as NAME (kept to switch back to; 3 to 16 letters,
|
||||
/// digits or _), or as the name the game made up
|
||||
/// Play offline (LAN, offline-mode servers) beside a signed-in account that owns the game:
|
||||
/// as NAME (kept to switch back to; 3 to 16 letters, digits or _), or as the name the game
|
||||
/// made up
|
||||
Offline { name: Option<String> },
|
||||
/// Forget one of your offline names
|
||||
ForgetOffline { name: String },
|
||||
@@ -556,7 +558,7 @@ enum Cmd {
|
||||
#[arg(long)]
|
||||
version: Option<String>,
|
||||
},
|
||||
/// Start the client
|
||||
/// Start the client (needs a signed-in Microsoft account that owns Minecraft: Java Edition)
|
||||
Play {
|
||||
#[arg(long)]
|
||||
version: Option<String>,
|
||||
@@ -1312,9 +1314,13 @@ fn account_command(action: AccountCmd, as_json: bool) -> Result<(), String> {
|
||||
"selected": accounts.player() == account::Player::Offline(n.clone()),
|
||||
})).collect::<Vec<_>>(),
|
||||
"sign_in_configured": account::client_id().is_some(),
|
||||
"owner": accounts.has_owner(account::store()),
|
||||
}));
|
||||
} else if accounts.accounts.is_empty() && accounts.offline.is_empty() {
|
||||
println!("No Microsoft account signed in: the game plays offline.");
|
||||
println!(
|
||||
"No Microsoft account signed in. Play needs one that owns Minecraft: Java \
|
||||
Edition: horizon-launcher account sign-in"
|
||||
);
|
||||
} else {
|
||||
for a in &accounts.accounts {
|
||||
let mark = if accounts.selected.as_ref() == Some(&a.uuid) {
|
||||
@@ -1333,6 +1339,9 @@ fn account_command(action: AccountCmd, as_json: bool) -> Result<(), String> {
|
||||
"Playing offline as the game's own name (`account select NAME` to switch)."
|
||||
);
|
||||
}
|
||||
if !accounts.has_owner(account::store()) {
|
||||
println!("{}.", account::NEEDS_OWNER);
|
||||
}
|
||||
}
|
||||
}
|
||||
AccountCmd::SignIn => {
|
||||
@@ -1372,7 +1381,7 @@ fn account_command(action: AccountCmd, as_json: bool) -> Result<(), String> {
|
||||
),
|
||||
(None, None) => return Err(find(&accounts, &who).unwrap_err()),
|
||||
};
|
||||
accounts.play_as(player)?;
|
||||
accounts.play_as(account::store(), player)?;
|
||||
accounts.save()?;
|
||||
println!("The game plays as {name}.");
|
||||
}
|
||||
@@ -1381,7 +1390,7 @@ fn account_command(action: AccountCmd, as_json: bool) -> Result<(), String> {
|
||||
Some(n) => account::Player::Offline(n),
|
||||
None => account::Player::GameName,
|
||||
};
|
||||
accounts.play_as(player)?;
|
||||
accounts.play_as(account::store(), player)?;
|
||||
accounts.save()?;
|
||||
match accounts.name_arg() {
|
||||
Some(n) => println!("The game plays offline as {n}."),
|
||||
|
||||
+67
-32
@@ -207,6 +207,9 @@ struct Launcher {
|
||||
sign_in: Option<SignIn>,
|
||||
sign_in_state: Option<SignInState>,
|
||||
keychain: bool,
|
||||
/// Whether an account here owns the game, by the keychain's last answers (the owner gate);
|
||||
/// none until the keychain has been read.
|
||||
owner: Option<bool>,
|
||||
/// The game is waiting for its session (a token renewal) before it starts.
|
||||
starting_client: Option<String>,
|
||||
/// Join a friend: the code typed on the Play page, and the one the game
|
||||
@@ -411,6 +414,7 @@ impl Launcher {
|
||||
sign_in: None,
|
||||
sign_in_state: None,
|
||||
keychain: true,
|
||||
owner: None,
|
||||
starting_client: None,
|
||||
friend_code: String::new(),
|
||||
friend_focused: false,
|
||||
@@ -463,8 +467,11 @@ impl Launcher {
|
||||
let tx = this.tx.clone();
|
||||
let keychain_tx = this.tx.clone();
|
||||
// Asking the keychain can block (D-Bus), so not on this thread.
|
||||
let accounts = this.accounts.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = keychain_tx.send(Msg::Keychain(account::store().persistent()));
|
||||
let store = account::store();
|
||||
let _ = keychain_tx.send(Msg::Keychain(store.persistent()));
|
||||
let _ = keychain_tx.send(Msg::Owner(accounts.has_owner(store)));
|
||||
});
|
||||
std::thread::spawn(move || {
|
||||
let reused = backend::reuse_prepared_everywhere();
|
||||
@@ -632,17 +639,29 @@ impl Launcher {
|
||||
// The page reads is_prepared when drawn; `changed` redraws it.
|
||||
Msg::Reused(_) => self.installed = backend::installed(),
|
||||
Msg::Keychain(persistent) => self.keychain = persistent,
|
||||
Msg::Owner(owns) => self.owner = Some(owns),
|
||||
Msg::Playing(playing) => self.playing = playing,
|
||||
Msg::Session(session) => {
|
||||
if let Some(tag) = self.starting_client.take() {
|
||||
let mut cmd = backend::client_command(&tag);
|
||||
match session {
|
||||
Ok(Some(file)) => {
|
||||
cmd.arg("--session-file").arg(file);
|
||||
let (file, offline) = match session {
|
||||
Ok(pass) => pass,
|
||||
Err(account::NoOwner) => {
|
||||
self.owner = Some(false);
|
||||
self.client_error = Some(format!("{}.", tr(account::NEEDS_OWNER)));
|
||||
self.starting_pack = None;
|
||||
self.joining = None;
|
||||
self.quick_play = None;
|
||||
continue;
|
||||
}
|
||||
Ok(None) => {}
|
||||
// Still play, offline, and say why.
|
||||
Err(e) => self.client_error = Some(format!("Playing offline: {e}")),
|
||||
};
|
||||
self.owner = Some(true);
|
||||
if let Some(file) = file {
|
||||
cmd.arg("--session-file").arg(file);
|
||||
}
|
||||
// Still play, offline, and say why.
|
||||
if let Some(e) = offline {
|
||||
self.client_error = Some(format!("Playing offline: {e}"));
|
||||
}
|
||||
self.accounts = Accounts::load();
|
||||
// An offline name the player picked (the quick switch).
|
||||
@@ -896,6 +915,9 @@ impl Launcher {
|
||||
if changed_state {
|
||||
if let SignInState::Done(Ok(_)) = state {
|
||||
self.accounts = Accounts::load();
|
||||
// Sign-in refuses accounts that don't own the game.
|
||||
self.owner = Some(true);
|
||||
self.client_error = None;
|
||||
}
|
||||
self.sign_in_state = Some(state);
|
||||
changed = true;
|
||||
@@ -1061,23 +1083,19 @@ impl Launcher {
|
||||
let tx = self.tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
backend::complete_assets(&tag);
|
||||
let session = (|| {
|
||||
let mut accounts = Accounts::load();
|
||||
let Some((a, secrets)) = account::session_for_launch(
|
||||
&account::Endpoints::live(),
|
||||
account::store(),
|
||||
&mut accounts,
|
||||
)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
account::write_session_file(&backend::game_home().join("sessions"), &a, &secrets)
|
||||
.map(Some)
|
||||
})();
|
||||
let session = account::session_for_game(&backend::game_home());
|
||||
let _ = tx.send(Msg::Session(session));
|
||||
});
|
||||
}
|
||||
|
||||
/// Starts the Microsoft sign-in; Settings shows its code.
|
||||
fn start_sign_in(&mut self) {
|
||||
if let Some(old) = self.sign_in.replace(SignIn::start()) {
|
||||
old.cancel();
|
||||
}
|
||||
self.sign_in_state = Some(SignInState::Starting);
|
||||
}
|
||||
|
||||
fn choose_folder(&mut self, cx: &mut Context<Self>) {
|
||||
let paths = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
@@ -2270,8 +2288,28 @@ impl Launcher {
|
||||
{
|
||||
rows = rows.child(self.join_friend_row(prepared && !running, cx));
|
||||
// The top bar shows who plays; a row only when there is something to do about it:
|
||||
// offline, in a build that can sign in.
|
||||
if self.accounts.selected().is_none() && account::client_id().is_some() {
|
||||
// no account that owns the game (Play needs one), or offline.
|
||||
if self.owner == Some(false) {
|
||||
rows = rows.child(index_row(
|
||||
5,
|
||||
tr("Microsoft account"),
|
||||
Some(
|
||||
div()
|
||||
.child(format!("{}.", tr(account::NEEDS_OWNER)))
|
||||
.into_any_element(),
|
||||
),
|
||||
Some(
|
||||
button("gate-sign-in", "Sign in", Kind::Primary, &t)
|
||||
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.start_sign_in();
|
||||
this.go(Page::Settings);
|
||||
cx.notify();
|
||||
}))
|
||||
.into_any_element(),
|
||||
),
|
||||
&t,
|
||||
));
|
||||
} else if self.accounts.selected().is_none() && account::client_id().is_some() {
|
||||
rows = rows.child(index_row(
|
||||
5,
|
||||
tr("Playing offline"),
|
||||
@@ -3129,6 +3167,7 @@ impl Launcher {
|
||||
this.sign_in_state = Some(SignInState::Done(Err(e)));
|
||||
}
|
||||
let _ = this.accounts.save();
|
||||
this.owner = Some(this.accounts.has_owner(store));
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
@@ -3174,9 +3213,9 @@ impl Launcher {
|
||||
),
|
||||
Some(SignInState::Done(Ok(a))) => div().child(format!("Signed in as {}.", a.name)),
|
||||
Some(SignInState::Done(Err(e))) => div().text_color(t.danger).child(e.clone()),
|
||||
None if self.accounts.accounts.is_empty() => div().child(
|
||||
"Sign in to play on online servers with your name and skin. Without an account the game plays offline.",
|
||||
),
|
||||
None if self.accounts.accounts.is_empty() => div().child(tr(
|
||||
"Horizon needs a Microsoft account that owns Minecraft: Java Edition, like other launchers. It also plays online servers with your name and skin.",
|
||||
)),
|
||||
None => div().child(tr("Add another account.")),
|
||||
};
|
||||
let detail = if self.keychain || self.accounts.accounts.is_empty() {
|
||||
@@ -3190,8 +3229,7 @@ impl Launcher {
|
||||
.child(div().child(account::NO_KEYCHAIN))
|
||||
};
|
||||
let action = if !configured {
|
||||
// No client ID in this build: the button says what's coming and does nothing.
|
||||
Some(button("sign-in", "Coming soon", Kind::Ghost, &t).into_any_element())
|
||||
None
|
||||
} else if let Some(SignInState::Code(code)) = &self.sign_in_state {
|
||||
let (uri, user_code) = (code.verification_uri.clone(), code.user_code.clone());
|
||||
Some(
|
||||
@@ -3231,17 +3269,14 @@ impl Launcher {
|
||||
Some(
|
||||
button("sign-in", "Sign in with Microsoft", Kind::Secondary, &t)
|
||||
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.sign_in = Some(SignIn::start());
|
||||
this.sign_in_state = Some(SignInState::Starting);
|
||||
this.start_sign_in();
|
||||
cx.notify();
|
||||
}))
|
||||
.into_any_element(),
|
||||
)
|
||||
};
|
||||
n += 1;
|
||||
let title = if !configured {
|
||||
"Microsoft account (coming soon)"
|
||||
} else if self.accounts.accounts.is_empty() {
|
||||
let title = if self.accounts.accounts.is_empty() {
|
||||
"Microsoft account"
|
||||
} else {
|
||||
"Another account"
|
||||
|
||||
@@ -200,6 +200,8 @@ impl Session {
|
||||
"plays_as": accounts.selected().map(|a| a.name.clone()),
|
||||
"sign_in_configured": account::client_id().is_some(),
|
||||
"keychain": account::store().persistent(),
|
||||
// Play needs an account that owns the game (the owner gate).
|
||||
"owner": accounts.has_owner(account::store()),
|
||||
"sign_in": sign_in,
|
||||
}))
|
||||
.unwrap())
|
||||
|
||||
@@ -24,7 +24,9 @@ impl Launcher {
|
||||
|
||||
pub(super) fn play_as(&mut self, who: Player) {
|
||||
let mut accounts = Accounts::load();
|
||||
let done = accounts.play_as(who).and_then(|()| accounts.save());
|
||||
let done = accounts
|
||||
.play_as(account::store(), who)
|
||||
.and_then(|()| accounts.save());
|
||||
match done {
|
||||
Ok(()) => self.accounts = accounts,
|
||||
Err(e) => self.sign_in_state = Some(SignInState::Done(Err(e))),
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! The owner gate from the outside: with no account that owns Minecraft: Java Edition in the
|
||||
//! launcher, neither `horizon-launcher play` nor the MCP server's play starts the game.
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
|
||||
const REFUSED: &str = "Sign in with a Microsoft account that owns Minecraft: Java Edition to play.";
|
||||
|
||||
/// Fresh homes with one installed (not prepared) version and an offline name, as a player who
|
||||
/// played offline before the gate has.
|
||||
fn home(name: &str) -> PathBuf {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-owner-gate-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let version = root.join("launcher/versions/v2026.10.27.01");
|
||||
std::fs::create_dir_all(&version).unwrap();
|
||||
std::fs::write(version.join(".installed"), "").unwrap();
|
||||
std::fs::write(
|
||||
root.join("launcher/accounts.json"),
|
||||
r#"{"accounts":[],"selected":null,"offline":["Builder_1"],"offline_selected":"Builder_1"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
fn launcher(root: &Path, args: &[&str]) -> Command {
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_horizon-launcher"));
|
||||
cmd.args(args)
|
||||
.env("HORIZON_HOME", root.join("game"))
|
||||
.env("HORIZON_LAUNCHER_HOME", root.join("launcher"))
|
||||
.env("XDG_DATA_HOME", root.join("data"))
|
||||
.env("XDG_CONFIG_HOME", root.join("config"))
|
||||
.env("HORIZON_LAUNCHER_NO_SELF_UPDATE", "1")
|
||||
.env("HORIZON_NO_AUDIO", "1")
|
||||
.env("HORIZON_TELEMETRY_URL", "off")
|
||||
// Memory only: the test never reads the player's keychain.
|
||||
.env("HORIZON_NO_KEYCHAIN", "1")
|
||||
.env("HORIZON_MSA_CLIENT_ID", "test-client");
|
||||
cmd
|
||||
}
|
||||
|
||||
fn text(out: &Output) -> String {
|
||||
String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cli_refuses_to_play_without_an_owner() {
|
||||
let root = home("cli");
|
||||
let out = launcher(&root, &["play"]).output().unwrap();
|
||||
let said = text(&out);
|
||||
assert!(!out.status.success(), "{said}");
|
||||
assert!(said.contains(REFUSED), "{said}");
|
||||
assert!(said.contains("horizon-launcher account sign-in"), "{said}");
|
||||
// Nothing of the game ran, and the offline name is still there.
|
||||
assert!(!root.join("game/sessions").exists());
|
||||
let accounts = std::fs::read_to_string(root.join("launcher/accounts.json")).unwrap();
|
||||
assert!(accounts.contains("Builder_1"), "{accounts}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mcp_server_refuses_to_play_without_an_owner() {
|
||||
let root = home("mcp");
|
||||
let mut child = launcher(&root, &["mcp"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let requests = [
|
||||
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#,
|
||||
r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"play","arguments":{}}}"#,
|
||||
];
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
for r in requests {
|
||||
writeln!(stdin, "{r}").unwrap();
|
||||
}
|
||||
drop(stdin);
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let reply = String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.map(|l| serde_json::from_str::<serde_json::Value>(l).unwrap())
|
||||
.find(|v| v["id"] == 2)
|
||||
.expect("a reply to play");
|
||||
assert_eq!(reply["result"]["isError"], true, "{reply}");
|
||||
let said = reply["result"]["content"][0]["text"].as_str().unwrap();
|
||||
assert!(said.contains(REFUSED), "{said}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
Reference in New Issue
Block a user