Launcher: Play downloads and prepares Minecraft for an owner

An owner with no Minecraft on the computer no longer needs to find
"Download and prepare": Play on a version not prepared yet prepares it
(from the player's install when there is one, else downloaded from
Mojang for the signed-in owner, Java included) and then starts the game,
as the official launcher does. Without an owner Play stays off.

With no install found, the Resources row offers the download alone and
the Minecraft folder row says none is needed. The CLI's not-prepared
error and the READMEs stop asking for a local install and Java.

The download path itself (download_for_owner, mojang.rs) is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-28 20:51:58 +01:00
1 parent 1d6d4cf0a0
commit dc943f95e8
7 files changed
+107 -18

No files matched your search

+3 -3
View File
@@ -29,9 +29,9 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that re
## Download
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/horizon-rust/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.
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/horizon-rust/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 copy of Minecraft (your own install, or downloaded from Mojang for your account), and updates itself.
**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).
**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)) and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Nothing else: with no Minecraft on your computer, Play downloads 26.3 and the Java it needs from Mojang for your account, as the official launcher does, then prepares it once. An install you already have (the official launcher, Prism, MultiMC and other launchers all work) is used instead. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
<table>
<tr>
@@ -93,7 +93,7 @@ Commands block until the action is done and reply with what happened; errors are
For developers. Requirements: Rust 1.90 or newer, Python 3.10 or newer, JDK 25, and on Linux the ALSA and udev development headers (`libasound2-dev libudev-dev` on Debian and Ubuntu). Development and the vanilla harnesses run on Linux.
Horizon contains no game files and never downloads them. It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
Horizon contains no game files, and `horizon-prepare` never downloads them (the launcher downloads them from Mojang only for a signed-in account that owns the game). It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
1. **Client:** install Java Edition with the official launcher and play 26.3 once. Horizon reads `versions/26.3/`, `libraries/` and `assets/` from the launcher's game directory (`~/.minecraft` on Linux). If yours lives elsewhere, pass `--minecraft-dir` or set `HORIZON_MINECRAFT_DIR`.
2. **Server jar:** the 26.3 server jar, from the `downloads.server.url` in your launcher's `versions/26.3/26.3.json` (or [minecraft.net/download/server](https://www.minecraft.net/en-us/download/server) while 26.3 is the current release). Pass it with `--server-jar` or set `HORIZON_SERVER_JAR`. It is only needed the first time.
+1 -1
View File
@@ -15,7 +15,7 @@ missing; [Rust rewrite status](rust-parity.md) has the details.
## Run from a checkout
Use current stable Rust (1.90+) and JDK 25 (Python 3.10+ only for the development
harnesses). Horizon never
harnesses). A checkout never
downloads the game: resource preparation imports **your own copy** (the 26.3
client from your official launcher install, and the 26.3 server jar you
downloaded yourself; see [Build from source](../README.md#build-from-source)),
+5 -1
View File
@@ -31,7 +31,11 @@ The opening, then Play ([clip](docs/opening.webm)):
system's libraries, the asset index and the assets. Every file must have the size and sha1
Mojang lists. The Play page shows how far it is and has Cancel; files already whole are kept,
so the next try goes on from there. **From my install** prepares from the player's own launcher
instead, and an install that lacks the version falls back to the download. Without an owner,
instead, and an install that lacks the version falls back to the download. **Play** on a version
not prepared yet does it all for an owner, as the official launcher does: it prepares from the
install when there is one, else downloads (with Mojang's Java when no Java 25 is installed), then
starts the game. With no install on the computer the Play page offers the download alone and the
Minecraft folder row says none is needed. Without an owner,
the Play page asks to sign in first. `horizon-launcher prepare --download`.
- **Mods**: search Modrinth (CurseForge support is coming soon) for resource
packs and modpacks, each marked Works, Partly or Doesn't run in
+2
View File
@@ -99,6 +99,7 @@
"Preparing…": "Préparation…",
"Prepare": "Préparer",
"Prepare again": "Préparer à nouveau",
"None needed: Horizon downloads Minecraft from Mojang for your account.": "Aucun dossier nécessaire : Horizon télécharge Minecraft depuis Mojang pour votre compte.",
"Not found. Choose the folder where your launcher keeps Minecraft.": "Introuvable. Choisissez le dossier où votre launcher garde Minecraft.",
"Ready to play": "Prêt à jouer",
"Resources": "Ressources",
@@ -175,6 +176,7 @@
"Open {link} in your browser and enter this code:": "Ouvrez {link} dans votre navigateur et entrez ce code :",
"From my install": "Depuis mon installation",
"Download and prepare": "Télécharger et préparer",
"Not prepared. Play downloads Minecraft {version} from Mojang for your account and prepares it.": "Pas préparé. Jouer télécharge Minecraft {version} depuis Mojang pour votre compte et le prépare.",
"Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.": "Pas préparé. Horizon peut télécharger Minecraft {version} depuis Mojang pour votre compte, ou utiliser votre propre installation.",
"Not prepared. Sign in with the Microsoft account that owns Minecraft {version} first.": "Pas préparé. Connectez-vous d'abord avec le compte Microsoft qui possède Minecraft {version}.",
"Cancel": "Annuler",
+2
View File
@@ -99,6 +99,7 @@
"Preparing…": "Подготовка…",
"Prepare": "Подготовить",
"Prepare again": "Подготовить заново",
"None needed: Horizon downloads Minecraft from Mojang for your account.": "Папка не нужна: Horizon скачает Minecraft у Mojang для вашего аккаунта.",
"Not found. Choose the folder where your launcher keeps Minecraft.": "Не найдено. Выберите папку, где ваш лаунчер хранит Minecraft.",
"Ready to play": "Готово к игре",
"Resources": "Ресурсы",
@@ -175,6 +176,7 @@
"Open {link} in your browser and enter this code:": "Откройте {link} в браузере и введите этот код:",
"From my install": "Из моей установки",
"Download and prepare": "Скачать и подготовить",
"Not prepared. Play downloads Minecraft {version} from Mojang for your account and prepares it.": "Не подготовлено. «Играть» скачает Minecraft {version} у Mojang для вашего аккаунта и подготовит его.",
"Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.": "Не подготовлено. Horizon может скачать Minecraft {version} у Mojang для вашего аккаунта или взять вашу установку.",
"Not prepared. Sign in with the Microsoft account that owns Minecraft {version} first.": "Не подготовлено. Сначала войдите в аккаунт Microsoft, у которого есть Minecraft {version}.",
"Cancel": "Отмена",
+4 -2
View File
@@ -1928,8 +1928,10 @@ pub fn need_prepared(tag: &str) -> Result<(), String> {
Ok(())
} else {
Err(format!(
"{tag} is not prepared yet. Prepare it first (it needs your own Minecraft {}).",
game_version(tag)
"{tag} is not prepared yet. Prepare it first: `horizon-launcher prepare --version {tag}` \
uses your own Minecraft {v}, or downloads it from Mojang for the signed-in account \
that owns it.",
v = game_version(tag)
))
}
}
+90 -11
View File
@@ -226,6 +226,8 @@ struct Launcher {
dev_options: bool,
/// The game is waiting for its session (a token renewal) before it starts.
starting_client: Option<String>,
/// Play was pressed on a version not prepared yet: it starts once this preparation ends well.
play_when_prepared: Option<String>,
/// Join a friend: the code typed on the Play page, and the one the game
/// starting now joins.
friend_code: String,
@@ -436,6 +438,7 @@ impl Launcher {
dev_clicks: 0,
dev_options: false,
starting_client: None,
play_when_prepared: None,
friend_code: String::new(),
friend_focused: false,
friend_focus: cx.focus_handle(),
@@ -921,6 +924,13 @@ impl Launcher {
match job {
Job::Prepare => {
self.preparing = false;
// Play pressed before the version was prepared plays now.
if let Some(tag) = self.play_when_prepared.take() {
if error.is_none() && backend::is_prepared(&tag) {
self.starting_pack = None;
self.start_client(tag);
}
}
if let (None, Some(started)) = (&error, self.prepare_started.take()) {
self.perf.record_since("launcher.prepare_ms", started);
}
@@ -1544,6 +1554,27 @@ fn segments<T: Copy + PartialEq + 'static>(
}))
}
/// What the Play button does for the selected version.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PlayPress {
Play,
/// Not prepared, for an account that owns the game: prepare it (from the player's install,
/// else downloaded from Mojang for that account), then play, as the official launcher does.
PrepareThenPlay,
/// Not prepared and no owner (signing in comes first), or busy preparing or playing.
Nothing,
}
/// `owner`: whether an account here owns the game, by the keychain (`Launcher::owner`).
fn play_press(prepared: bool, owner: Option<bool>, busy: bool) -> PlayPress {
match (prepared, owner) {
_ if busy => PlayPress::Nothing,
(true, _) => PlayPress::Play,
(false, Some(true)) => PlayPress::PrepareThenPlay,
(false, _) => PlayPress::Nothing,
}
}
/// The selected version once one has been removed: the saved choice if it is still installed,
/// else the newest left.
fn selection_after_removal(installed: &[String], saved: Option<String>) -> Option<String> {
@@ -2303,14 +2334,23 @@ impl Launcher {
.top(px(17.))
.child(blocks::icon("play", 22., t.on_face)),
);
if !prepared || self.preparing || running {
play = disabled(play);
} else {
play = play.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.picker_open = false;
this.play();
cx.notify();
}));
match play_press(prepared, self.owner, self.preparing || running) {
PlayPress::Nothing => play = disabled(play),
PlayPress::Play => {
play = play.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.picker_open = false;
this.play();
cx.notify();
}))
}
PlayPress::PrepareThenPlay => {
play = play.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.picker_open = false;
this.play_when_prepared = this.selected().cloned();
this.prepare(backend::MinecraftFrom::Install);
cx.notify();
}))
}
}
let actions = div()
.flex()
@@ -2338,8 +2378,14 @@ impl Launcher {
let first = (!prepared && !self.preparing)
.then_some(self.owner_shown())
.flatten();
// No launcher folder with Minecraft on this computer: an owner needs none.
let no_install = self.settings.minecraft_dir.is_none();
let resources_value = match first {
_ if self.preparing => tr("Preparing…").to_string(),
Some(true) if no_install => trf(
"Not prepared. Play downloads Minecraft {version} from Mojang for your account and prepares it.",
&[("version", &version)],
),
Some(true) => trf(
"Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.",
&[("version", &version)],
@@ -2369,6 +2415,18 @@ impl Launcher {
}));
}
let prepare = match first {
Some(true) if no_install => div().child(
button(
"prepare-download",
"Download and prepare",
Kind::Secondary,
&t,
)
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.prepare(backend::MinecraftFrom::Mojang);
cx.notify();
})),
),
Some(true) => div()
.flex()
.gap(px(8.))
@@ -2402,12 +2460,15 @@ impl Launcher {
),
None => div().child(prepare),
};
let owner = self.owner == Some(true);
let minecraft = self.settings.minecraft_dir.as_deref().map_or_else(
|| {
div()
.child(tr(
"Not found. Choose the folder where your launcher keeps Minecraft.",
))
.child(if owner {
tr("None needed: Horizon downloads Minecraft from Mojang for your account.")
} else {
tr("Not found. Choose the folder where your launcher keeps Minecraft.")
})
.into_any_element()
},
|p| {
@@ -4430,6 +4491,24 @@ mod tests {
);
}
/// An owner's Play works on a version not prepared yet: it prepares it (downloading
/// Minecraft from Mojang when this computer has no install), then plays. Without an owner
/// it stays off: signing in comes first.
#[test]
fn play_prepares_first_for_an_owner() {
use super::{play_press, PlayPress};
assert_eq!(play_press(true, Some(true), false), PlayPress::Play);
assert_eq!(play_press(true, None, false), PlayPress::Play);
assert_eq!(
play_press(false, Some(true), false),
PlayPress::PrepareThenPlay
);
assert_eq!(play_press(false, Some(false), false), PlayPress::Nothing);
assert_eq!(play_press(false, None, false), PlayPress::Nothing);
assert_eq!(play_press(false, Some(true), true), PlayPress::Nothing);
assert_eq!(play_press(true, Some(true), true), PlayPress::Nothing);
}
#[test]
fn the_app_menu_has_quit() {
let menus = super::menus();