Launcher: the Nightly is the only game build, for everyone

Step 3 of Nightly only (Ludovic, 2026-10-28). Every launcher installs and updates only the
Nightly, with no setting:

- The update check reads the `nightly` pre-release from the release list it already fetches and
  offers it as the update, installed as `nightly-<date>-<commit>` after its checksums signed for
  the Nightly channel check. Nightlies rank after every release (`ReleaseOrder::Nightly`); a
  second build of the same day is an update too.
- A player on the newest release, on a Nightly or on nothing moves to the new Nightly at the next
  check; a release picked by hand and instances pinned to a version stay. Worlds live in the
  game's folder, so nothing touches them. Older Nightlies go once nothing plays them.
- The "Nightly builds" setting, its separate install path and its `nightly` key are gone; the old
  "Nightly" instance stays and keeps following the Nightly.
- Versions lists the current Nightly, the last week's and the releases from before; the update
  line names a Nightly by its title.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-28 20:36:24 +01:00
1 parent a1e56a4d1b
commit a1bdb0d22d
7 files changed
+257 -272

No files matched your search

+17 -11
View File
@@ -12,8 +12,9 @@ The opening, then Play ([clip](docs/opening.webm)):
## What it does
- **Versions**: lists the published releases of horizon-rust/horizon, installs the build for
this platform into its own folder, and removes it again.
- **Versions**: lists the published builds of horizon-rust/horizon (the current Nightly, the last
week's, then the releases from before), installs the build for this platform into its own
folder, and removes it again.
- **Play**: prepares resources as `PLAY.txt` describes (your launcher's 26.3 install, the
26.3 server jar from that version's own json, and Java 25) and copies its sounds from your
launcher's asset store (`horizon-prepare sounds`; a launcher install without them only
@@ -212,11 +213,16 @@ page `servers.json` (the list), `servers/<name>/` (the servers created there) an
when the game starts and brings it back to the front when the game closes; `settings
while-playing stay|minimize|hide`), the game's extra command-line arguments, an HTTP proxy for
downloads, and the launcher's own version and update state.
- **Updates**: there is one channel. Every release is published as the release, GitHub's latest,
once its smoke test passed (docs/release-flow.md), and the launcher installs it. The game
pre-releases of the old Alpha channel (v0.2.1 on) stay on the Versions page to install
by hand, but are never installed as an update. The `update_channel` and `beta_builds` keys
older launchers wrote in `settings.json` are ignored and dropped on the next save.
- **Updates**: Horizon ships only the Nightly (since 2026-10-28, docs/release-flow.md). The
launcher installs the current one (the `nightly` pre-release, checksums signed for the Nightly
channel) as `nightly-<date>-<commit>` and selects it when the player was on a Nightly, on the
newest release or on nothing; a version picked by hand or pinned by an instance stays. Older
Nightlies are removed once nothing plays them. Worlds are never touched: they live in the game's
folder, not in a version's. The last week's Nightlies (`nightly-<date>`), the `vX.Y.Z`
releases and the old Alpha pre-releases stay on the Versions page to install by hand, never as
an update. The "Nightly builds" setting and its `nightly` key are gone (an old key is ignored);
its "Nightly" instance stays and keeps following the Nightly. The `update_channel` and
`beta_builds` keys older launchers wrote are ignored and dropped on the next save.
- **Game arguments**: extra arguments added to the game's command line (for example
`--server-thread`), one line where quotes group a word with spaces. They are appended after the
launcher's own arguments, kept as `game_args` in `settings.json`, and apply the next time the
@@ -244,11 +250,11 @@ minimize, maximize/restore and close; macOS and Linux keep their system title ba
## Updates
The launcher looks for a newer Horizon release when it starts and every 30 minutes while it
is open, and shows a small "available, Install" line on Play.
The launcher looks for a newer Nightly when it starts and every 30 minutes while it is open, and
shows a small "available, Install" line on Play.
It also updates itself from GitHub releases tagged `[email protected]` (Horizon's own are `vX.Y.Z`;
neither is ever taken for the other). Before 0.2 they were `launcher-vX.Y.Z` and `vYYYY.MM.DD`:
It also updates itself from GitHub releases tagged `[email protected]` (the game's are `nightly`, and
`vX.Y.Z` before 2026-10-28; neither is ever taken for the other). Before 0.2 they were `launcher-vX.Y.Z` and `vYYYY.MM.DD`:
every 0.2 tag counts as newer than all of those (docs/versioning.md). It downloads the new build, replaces its
own binary, and offers "Restart" in the footer. Development builds and
`HORIZON_LAUNCHER_NO_SELF_UPDATE=1` never replace themselves.
-7
View File
@@ -157,13 +157,6 @@
"Not prepared. Horizon needs the files of your own Minecraft {version}.": "Pas préparé. Horizon a besoin des fichiers de votre propre Minecraft {version}.",
"Prepared for Minecraft {version}": "Préparé pour Minecraft {version}",
"Prepared for Minecraft {version}, without its sounds: the game will be silent. Prepare again to copy them from your Minecraft install.": "Préparé pour Minecraft {version}, sans ses sons : le jeu sera muet. Préparez à nouveau pour les copier depuis votre installation de Minecraft.",
"Nightly builds": "Versions Nightly",
"Can include pull requests not reviewed yet, picked by trusted reviewers; may break.": "Peut inclure des pull requests pas encore relues, choisies par des relecteurs de confiance ; peut casser.",
"Plays in its own Nightly instance: your worlds and the release stay as they are.": "Se joue dans sa propre instance Nightly : vos mondes et la version publiée restent tels quels.",
"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.",
"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.",
-7
View File
@@ -157,13 +157,6 @@
"Not prepared. Horizon needs the files of your own Minecraft {version}.": "Не подготовлено. Horizon нужны файлы вашего собственного Minecraft {version}.",
"Prepared for Minecraft {version}": "Подготовлено для Minecraft {version}",
"Prepared for Minecraft {version}, without its sounds: the game will be silent. Prepare again to copy them from your Minecraft install.": "Подготовлено для Minecraft {version}, но без звуков: игра будет беззвучной. Подготовьте заново, чтобы скопировать их из вашей установки Minecraft.",
"Nightly builds": "Ночные сборки",
"Can include pull requests not reviewed yet, picked by trusted reviewers; may break.": "Может включать ещё не проверенные pull request'ы, выбранные доверенными ревьюерами; что-то может сломаться.",
"Plays in its own Nightly instance: your worlds and the release stay as they are.": "Запускается в отдельной сборке Nightly: ваши миры и релиз остаются как есть.",
"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.": "Ночных сборок пока нет.",
"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. С ним вы также играете на онлайн-серверах под своим именем и со своим скином.",
+149 -26
View File
@@ -37,9 +37,13 @@ pub struct Release {
pub body: String,
pub date: String,
pub asset: Option<(String, u64)>,
/// A GitHub pre-release: the Alpha builds from before every release went out as the release
/// (v0.2.1 on, docs/release-flow.md). Listed and installed by hand, never as an update.
/// Listed and installed by hand, never as an update: a GitHub pre-release of the old Alpha
/// channel (v0.2.1 on), or a Nightly older than the current one.
pub prerelease: bool,
/// A Nightly's GitHub tag (`nightly`, or `nightly-<date>` for the last week's): its files and
/// signature are there, while `tag` is the version it installs as (`nightly-2026-10-28-0c02067`).
/// None for a release.
pub nightly: Option<String>,
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
@@ -154,6 +158,9 @@ pub enum ReleaseOrder {
/// Numbers without trailing zeros, so 1.2 and 1.2.0 are equal.
Before02(Vec<u64>),
Semver([u64; 3], Prerelease),
/// A Nightly (`nightly-<date>-<commit>`), by its date: newer than every release, since the
/// Nightly is the only build Horizon ships.
Nightly(String),
}
/// (is a release, the pre-release's identifiers): a release follows every pre-release of its
@@ -170,6 +177,9 @@ pub enum Identifier {
const FIRST_YEAR: u64 = 1000;
pub fn release_order(tag: &str) -> ReleaseOrder {
if let Some(date) = crate::nightly::date_of(tag) {
return ReleaseOrder::Nightly(date.to_owned());
}
let semver = tag
.strip_prefix(LAUNCHER_TAG_PREFIX)
.or_else(|| tag.strip_prefix('v'))
@@ -270,6 +280,7 @@ pub(crate) fn parse_releases(
.collect(),
asset: found,
prerelease: r["prerelease"].as_bool().unwrap_or(false),
nightly: None,
});
}
out.sort_by(|a, b| compare_versions(&b.tag, &a.tag));
@@ -280,10 +291,30 @@ fn is_launcher_tag(tag: &str) -> bool {
launcher_version(tag).is_some()
}
/// Published Horizon releases, newest first. Drafts never reach the anonymous API, and launcher
/// releases are left out.
/// Published Horizon builds, newest first: the current Nightly (the only build Horizon ships since
/// 2026-10-28, docs/release-flow.md), the last week's Nightlies, then the releases from before.
/// Drafts never reach the anonymous API, and launcher releases are left out.
pub fn releases() -> Result<Vec<Release>, String> {
Ok(parse_releases(&release_list()?, ASSET, is_game_tag))
Ok(game_releases(&release_list()?))
}
/// The game's builds in `list`, newest first. A Nightly is listed under the version it installs as
/// ([`crate::nightly::version_of`]); only the current one (`nightly`) is an update.
pub(crate) fn game_releases(list: &serde_json::Value) -> Vec<Release> {
let mut out = parse_releases(list, ASSET, |tag| {
is_game_tag(tag) || horizon_release_sign::is_nightly(tag)
});
for r in &mut out {
if horizon_release_sign::is_nightly(&r.tag) {
let version = crate::nightly::version_of(r);
let tag = std::mem::replace(&mut r.tag, version);
r.prerelease = tag != horizon_release_sign::NIGHTLY;
r.nightly = Some(tag);
}
}
// Two Nightlies of one day: the current one first (the dated one is the day's earlier build).
out.sort_by(|a, b| compare_versions(&b.tag, &a.tag).then(a.prerelease.cmp(&b.prerelease)));
out
}
/// The launcher releases in `list` (the newest 100 releases). Game releases come out several times
@@ -556,10 +587,17 @@ fn unpack(archive: &Path, url: &str, to: &Path) -> Result<(), String> {
/// the fraction done.
pub fn install(release: &Release, progress: impl Fn(f32)) -> Result<(), String> {
let tag = release.tag.as_str();
if !is_game_tag(tag) {
return Err(format!("{tag} is not a Horizon release."));
}
let sha256 = signed_digest(tag, ASSET.unwrap_or_default())?;
let sha256 = match &release.nightly {
// Signed for the Nightly channel, served under its own GitHub tag.
Some(published) => crate::signed::nightly_digest(
published,
ASSET.unwrap_or_default(),
&horizon_release_sign::trusted_keys(),
fetch_small,
)?,
None if is_game_tag(tag) => signed_digest(tag, ASSET.unwrap_or_default())?,
None => return Err(format!("{tag} is not a Horizon release.")),
};
install_as(release, tag, &sha256, progress)
}
@@ -2053,12 +2091,15 @@ pub struct Updates {
pub launcher: Option<Release>,
}
/// The newest Horizon release with a build here, if it is newer than every installed version.
/// The newest Horizon build with a build here (the current Nightly), if it isn't installed and
/// no installed version is newer. Two Nightlies of one day compare equal: the one on GitHub is
/// the update.
pub fn horizon_update(releases: &[Release], installed: &[String]) -> Option<Release> {
let newest = latest(releases)?;
let newer = installed
.iter()
.all(|t| compare_versions(&newest.tag, t).is_gt());
let newer = !installed.contains(&newest.tag)
&& installed
.iter()
.all(|t| compare_versions(&newest.tag, t).is_ge());
newer.then(|| newest.clone())
}
@@ -2098,7 +2139,7 @@ fn releases_and_updates_with(
return Err(e);
}
};
let releases = parse_releases(&list, ASSET, is_game_tag);
let releases = game_releases(&list);
let horizon = horizon_update(&installable_updates(&releases), &installed());
on_releases(Ok(releases));
Ok(Updates {
@@ -2112,12 +2153,18 @@ pub fn check_updates() -> Result<Updates, String> {
releases_and_updates(|_| {})
}
/// Whether a version installed as an update becomes the selected one: only when the player was
/// on the newest installed version before (or had none).
/// Whether a version installed as an update becomes the selected one: when the player had none,
/// was on a Nightly, or on the newest installed release (so a player of the release channel moves
/// to the Nightly at the first check, docs/release-flow.md). An older release picked by hand
/// stays; an instance pinned to a version keeps it either way.
pub fn select_after_update(selected: Option<&str>, installed_before: &[String]) -> bool {
match selected {
None => true,
Some(s) => installed_before.first().is_none_or(|newest| newest == s),
Some(s) if crate::nightly::date_of(s).is_some() => true,
Some(s) => installed_before
.iter()
.find(|t| is_game_tag(t))
.is_none_or(|newest| newest == s),
}
}
@@ -2146,6 +2193,9 @@ pub fn install_tag(tag: &str, as_update: bool, progress: impl Fn(f32)) -> Result
settings.selected = Some(release.tag.clone());
settings.save();
}
if as_update && release.nightly.is_some() {
crate::nightly::after_update(&release.tag);
}
Ok(release.tag.clone())
}
@@ -2473,7 +2523,8 @@ pub enum Msg {
Removed(String, Result<(), String>),
Line(Job, String),
Exit(Job, Result<(), String>),
Updates(Result<Updates, String>),
/// Boxed: two releases, the largest message by far.
Updates(Result<Box<Updates>, String>),
LauncherProgress(f32),
LauncherUpdated(Result<(), String>),
/// `reuse_prepared_everywhere` prepared these versions.
@@ -2491,10 +2542,6 @@ pub enum Msg {
PackDone(Result<Box<crate::modpacks::Imported>, String>),
/// How many play Horizon now, or none when the server can't be reached (`telemetry`).
Playing(Option<u64>),
/// The nightly being installed, done this far (`nightly`).
NightlyProgress(f32),
/// A nightly check ended: the version installed (None: already up to date), or why not.
Nightly(Result<Option<String>, String>),
/// Where the native-mods build is (`native_mods::Step::label`).
NativeProgress(String),
/// The native-mods build ended: the version built, or why not.
@@ -2681,10 +2728,6 @@ pub struct Settings {
/// The window's language. Unset follows the system's (`i18n::Language::system`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<crate::i18n::Language>,
/// The player turned Nightly on (`nightly`): the nightly build installs and updates in its own
/// instance. Off by default.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub nightly: bool,
/// The player turned Native mods (experimental) on (`native_mods`): mixin mods compiled into
/// a game of their own. Off by default.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
@@ -3148,6 +3191,7 @@ mod tests {
date: String::new(),
asset: Some(("u".into(), 1)),
prerelease: false,
nightly: None,
};
assert!(launcher_update(&[r(own_tag())]).is_none());
assert_eq!(launcher_version(&own_tag()), Some(VERSION));
@@ -3188,6 +3232,7 @@ mod tests {
date: String::new(),
asset: Some(("u".into(), 1)),
prerelease: false,
nightly: None,
};
let mut list = vec![
r("launcher-v0.16.0"),
@@ -3354,6 +3399,7 @@ mod tests {
date: String::new(),
asset: Some(("u".into(), 1)),
prerelease: false,
nightly: None,
};
let releases = [r("v2026.10.13"), r("v2026.10.12")];
assert_eq!(
@@ -3370,6 +3416,83 @@ mod tests {
assert!(select_after_update(None, &installed));
}
/// The Nightly is the only build Horizon ships (docs/release-flow.md): every launcher takes the
/// current one as its update, over every release; the last week's are listed, never offered.
#[test]
fn every_launcher_updates_to_the_current_nightly() {
let asset = ASSET.unwrap_or("horizon-linux-x86_64.tar.gz");
let build = |url: &str| serde_json::json!([{"name": asset, "browser_download_url": url, "size": 1}]);
let list = serde_json::json!([
{"tag_name": "nightly", "name": "Nightly 2026-10-28 · 9b772b2", "prerelease": true,
"published_at": "2026-10-28T09:58:01Z", "assets": build("n")},
{"tag_name": "[email protected]", "assets": []},
{"tag_name": "v0.2.95", "assets": build("r")},
{"tag_name": "nightly-2026-10-28", "name": "Nightly 2026-10-28 · 83f15fa", "prerelease": true,
"published_at": "2026-10-28T07:54:57Z", "assets": build("d")},
{"tag_name": "nightly-2026-10-27", "name": "Nightly 2026-10-27 · 7954902", "prerelease": true,
"published_at": "2026-10-28T00:15:40Z", "assets": build("e")},
]);
let releases = game_releases(&list);
let listed: Vec<_> = releases
.iter()
.map(|r| (r.tag.as_str(), r.nightly.as_deref(), r.prerelease))
.collect();
assert_eq!(
listed,
[
("nightly-2026-10-28-9b772b2", Some("nightly"), false),
(
"nightly-2026-10-28-83f15fa",
Some("nightly-2026-10-28"),
true
),
(
"nightly-2026-10-27-7954902",
Some("nightly-2026-10-27"),
true
),
("v0.2.95", None, false),
]
);
let offered = |installed: &[&str]| {
let installed: Vec<String> = installed.iter().map(|t| t.to_string()).collect();
horizon_update(&installable_updates(&releases), &installed).map(|r| r.tag)
};
let current = Some("nightly-2026-10-28-9b772b2".to_string());
// A player of the release channel, a new player, one on an older Nightly of the same day.
assert_eq!(offered(&["v0.2.95", "v0.2.94"]), current);
assert_eq!(offered(&[]), current);
assert_eq!(offered(&["nightly-2026-10-28-83f15fa", "v0.2.95"]), current);
assert_eq!(offered(&["nightly-2026-10-28-9b772b2"]), None);
// Nightlies come after every release in every list.
assert!(compare_versions("nightly-2026-10-27-7954902", "v0.2.95").is_gt());
assert!(
compare_versions("nightly-2026-10-28-9b772b2", "nightly-2026-10-27-7954902").is_gt()
);
}
/// Players move to the Nightly at their next check: whoever was on the newest release, on a
/// Nightly, or on nothing. A release picked by hand stays picked.
#[test]
fn players_of_the_newest_release_move_to_the_nightly() {
let installed = [
"nightly-2026-10-27-7954902".to_string(),
"v0.2.95".into(),
"v0.2.90".into(),
];
assert!(select_after_update(Some("v0.2.95"), &installed));
assert!(select_after_update(
Some("nightly-2026-10-27-7954902"),
&installed
));
assert!(select_after_update(None, &installed));
assert!(!select_after_update(Some("v0.2.90"), &installed));
assert!(select_after_update(
Some("v0.2.95"),
&["v0.2.95".to_string()]
));
}
#[test]
fn new_versions_install_automatically_unless_turned_off() {
// No settings file, an old one that never mentioned it, and 0.1.1's default `false`.
+14 -126
View File
@@ -54,7 +54,6 @@ use horizon_launcher::backend::{self, Job, Msg, Release, Running, Settings, Upda
use horizon_launcher::i18n::{self, tr, trf};
use horizon_launcher::instances;
use horizon_launcher::native_mods;
use horizon_launcher::nightly;
use horizon_launcher::telemetry;
use scrollbar::Scrollbar;
use std::borrow::Cow;
@@ -171,12 +170,6 @@ struct Launcher {
/// nothing can launch, select or prepare it, and its row can't install or remove it again.
removing: Option<String>,
install_error: Option<(String, String)>,
/// Nightly (`horizon_launcher::nightly`): how far the nightly being installed is, and why the
/// last check or install failed.
nightly_progress: Option<f32>,
nightly_error: Option<String>,
/// Play the Nightly instance once its first install ends: the player just turned Nightly on.
nightly_select: bool,
/// Native mods (`native_mods`): the build's step while it runs, why it failed, and the
/// warning before a build while it is open, with the mods it names.
native_step: Option<String>,
@@ -392,9 +385,6 @@ impl Launcher {
installing: None,
removing: None,
install_error: None,
nightly_progress: None,
nightly_error: None,
nightly_select: false,
native_step: None,
native_error: None,
native_confirm: None,
@@ -522,7 +512,7 @@ impl Launcher {
self.checked = Some(Instant::now());
let tx = self.tx.clone();
std::thread::spawn(move || {
let _ = tx.send(Msg::Updates(backend::check_updates()));
let _ = tx.send(Msg::Updates(backend::check_updates().map(Box::new)));
});
}
@@ -565,30 +555,6 @@ impl Launcher {
self.install(&release);
}
/// With Nightly on, installs the current nightly in the Nightly instance if it is new.
fn update_nightly(&mut self) {
if !self.settings.nightly || self.nightly_progress.is_some() {
return;
}
self.nightly_progress = Some(0.);
self.nightly_error = None;
let (tx, installed) = (self.tx.clone(), self.installed.clone());
std::thread::spawn(move || {
let result = nightly::latest().and_then(|latest| {
let Some(release) = nightly::update(latest, &installed) else {
return Ok(None);
};
let progress = |p| {
let _ = tx.send(Msg::NightlyProgress(p));
};
nightly::install(&release, progress).map(|_| Some(nightly::version_of(&release)))
});
let _ = tx.send(Msg::Nightly(result));
});
}
/// Turns Nightly on (install it, then play its instance) or off (play the release again; the
/// Nightly instance and its worlds stay).
/// The native mods turned on in the instance Play starts, unpacked and checked, with their
/// bundles: what the warning names and the build compiles.
fn native_mods_to_build(
@@ -639,17 +605,6 @@ impl Launcher {
});
}
fn set_nightly(&mut self, on: bool) {
self.settings.nightly = on;
if on {
self.nightly_select = true;
} else if self.settings.instance.as_deref() == Some(nightly::INSTANCE) {
self.settings.instance = None;
}
self.settings.save();
self.update_nightly();
}
fn fetch_releases(&mut self) {
self.releases = Load::Loading;
let tx = self.tx.clone();
@@ -665,7 +620,7 @@ impl Launcher {
let updates = backend::releases_and_updates(|releases| {
let _ = tx.send(Msg::Releases(releases));
});
let _ = tx.send(Msg::Updates(updates));
let _ = tx.send(Msg::Updates(updates.map(Box::new)));
});
}
@@ -826,17 +781,15 @@ impl Launcher {
}
Msg::Updates(Ok(updates)) => {
self.update_error = None;
self.updates = updates;
self.updates = *updates;
if self.settings.auto_install() {
self.install_update();
}
if let Some(release) = self.updates.launcher.clone() {
self.update_launcher(release);
}
self.update_nightly();
}
Msg::Updates(Err(e)) => self.update_error = Some(e),
Msg::NightlyProgress(p) => self.nightly_progress = Some(p),
Msg::NativeProgress(step) => self.native_step = Some(step),
Msg::Native(result) => {
self.native_step = None;
@@ -846,28 +799,6 @@ impl Launcher {
self.native_error = Some(e);
}
}
Msg::Nightly(result) => {
self.nightly_progress = None;
self.installed = backend::installed();
self.reload_instances();
match result {
Ok(_)
if self.nightly_select
&& self.settings.nightly
&& self
.instances
.list
.iter()
.any(|i| i.id == nightly::INSTANCE) =>
{
self.nightly_select = false;
self.settings.instance = Some(nightly::INSTANCE.into());
self.settings.save();
}
Ok(_) => {}
Err(e) => self.nightly_error = Some(e),
}
}
Msg::LauncherProgress(p) => {
if let SelfUpdate::Downloading(_, f) = &mut self.self_update {
*f = p;
@@ -1567,6 +1498,15 @@ fn page_header(title: &str, description: &str, t: &Theme) -> Div {
/// One row of a list, as its own panel: a name in the pixel face with an optional detail under
/// it, then a value or a control on the right. Rows were numbered in the quiet look; `_n` keeps
/// the call sites as they are.
/// How an available update is named: a Nightly by its title ("Nightly 2026-10-28 · 0c02067"), a
/// release by its tag.
fn update_name(release: &Release) -> &str {
match release.nightly {
Some(_) => &release.name,
None => &release.tag,
}
}
fn index_row(
_n: usize,
name: impl IntoElement,
@@ -2782,7 +2722,7 @@ impl Launcher {
.text_size(px(theme::CAPTION.px))
.text_color(t.muted)
.child(div().size(px(6.)).rounded_full().bg(t.accent))
.child(format!("{} available", release.tag));
.child(format!("{} available", update_name(release)));
hero = hero.child(match installing {
Some((_, p)) => line
.child(
@@ -2883,7 +2823,7 @@ impl Launcher {
}),
);
let horizon = match (&self.updates.horizon, &self.update_error) {
(Some(r), _) => trf("{version} available", &[("version", &r.tag)]),
(Some(r), _) => trf("{version} available", &[("version", update_name(r))]),
(None, Some(e)) => e.clone(),
(None, None) if self.checked.is_some() => tr("Up to date").to_string(),
_ => tr("Checking…").to_string(),
@@ -3011,7 +2951,6 @@ impl Launcher {
Some(toggle.into_any_element()),
&t,
))
.child(self.nightly_row(n + 2, cx))
.child(group("Launcher", "monitor"))
.child(index_row(
n + 3,
@@ -3091,57 +3030,6 @@ impl Launcher {
.children(self.agents_section(cx))
}
/// Settings' Nightly row: off by default; on, the nightly build installs and updates in its
/// own instance, and the release stays one click away (the Horizon instance).
fn nightly_row(&self, n: usize, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let on = self.settings.nightly;
let toggle =
switch("nightly", on, &t).on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.set_nightly(!this.settings.nightly);
cx.notify();
}));
let what = tr(
"Can include pull requests not reviewed yet, picked by trusted reviewers; may break.",
);
let status = match (&self.nightly_progress, &self.nightly_error) {
_ if !on => tr("Plays in its own Nightly instance: your worlds and the release stay as they are.")
.to_string(),
(Some(p), _) => trf(
"Installing the nightly, {percent}%",
&[("percent", &format!("{:.0}", p * 100.))],
),
(None, Some(e)) => trf("The nightly could not be installed: {error}", &[("error", e)]),
(None, None) => match self
.instances
.list
.iter()
.find(|i| i.id == nightly::INSTANCE)
.and_then(|i| i.version.clone())
{
Some(version) => trf(
"{version} in the Nightly instance. Choose Horizon in the sidebar to play the release.",
&[("version", &version)],
),
None => tr("No nightly build yet.").to_string(),
},
};
index_row(
n,
tr("Nightly builds"),
Some(
div()
.flex()
.flex_col()
.child(what.to_string())
.child(status)
.into_any_element(),
),
Some(toggle.into_any_element()),
&t,
)
}
/// Settings' Game arguments row: the extra arguments the game starts with, kept in
/// `settings.json` and appended to the launcher's own (quotes group a word with spaces).
/// Settings' Native mods row (docs/native-mods.md): off by default. On, Build compiles the
+71 -90
View File
@@ -1,19 +1,17 @@
//! Nightly (docs/release-flow.md "Nightly channel"): an opt-in build of main plus pull requests
//! picked by trusted reviewers, some not reviewed yet, published as the `nightly` pre-release each
//! night and when a reviewer asks. Off by default (`Settings::nightly`).
//! Nightly (docs/release-flow.md): the only build Horizon ships since 2026-10-28, main plus the pull
//! requests the trusted reviewers picked, published as the `nightly` pre-release a few times a day.
//! Every launcher installs it as its update (`backend::horizon_update`), as its own version
//! `nightly-<date>-<commit>`, beside the player's worlds, which it never touches. Its checksums must
//! be signed for the nightly channel (`signed::nightly_digest`).
//!
//! It never touches the release: it installs as its own version, `nightly-<date>-<commit>`, and plays
//! in its own instance, "Nightly" (`instances/nightly/` in the game's home), so the player's worlds
//! stay where they are and choosing the Horizon instance plays the release again. Its checksums
//! must be signed for the nightly channel (`signed::nightly_digest`); the release channel refuses
//! them.
use crate::backend::{self, Release, ASSET};
use crate::instances::{self, Instance};
//! Before that it was opt-in and played in its own "Nightly" instance (`instances/nightly/`):
//! that instance, and its worlds, stay, and keep following the Nightly.
use crate::backend::{self, Release};
use crate::instances;
use horizon_release_sign::NIGHTLY;
/// The Nightly instance's id (its folder in `instances/`) and name.
/// The id of the instance (its folder in `instances/`) the opt-in Nightly played in.
pub const INSTANCE: &str = "nightly";
const NAME: &str = "Nightly";
/// The version a nightly installs as: `nightly-2026-10-28-0c02067`, from its title
/// ("Nightly 2026-10-28 · 0c02067"), else its publication date.
@@ -33,6 +31,12 @@ pub fn version_of(release: &Release) -> String {
}
}
/// The date of a nightly's version (`nightly-2026-10-28-0c02067`), or None for any other version.
pub fn date_of(version: &str) -> Option<&str> {
let date = version.strip_prefix("nightly-")?.get(..10)?;
is_date(date).then_some(date)
}
fn is_date(text: &str) -> bool {
text.len() == 10
&& text.chars().enumerate().all(|(i, c)| {
@@ -44,112 +48,89 @@ fn is_date(text: &str) -> bool {
})
}
/// The current nightly build, or None while there is none.
pub fn latest() -> Result<Option<Release>, String> {
let url = format!(
"https://api.github.com/repos/{}/releases/tags/{NIGHTLY}",
backend::REPO
);
let release: serde_json::Value = match backend::get(&url) {
Ok(response) => response.into_json().map_err(|e| e.to_string())?,
Err(e) if e.ends_with("answered 404") => return Ok(None),
Err(e) => return Err(e),
};
Ok(parse(&release))
}
fn parse(release: &serde_json::Value) -> Option<Release> {
let list = serde_json::Value::Array(vec![release.clone()]);
backend::parse_releases(&list, ASSET, |tag| tag == NIGHTLY)
/// After `version`, a Nightly, installed as an update: the old Nightly instance follows it, and the
/// older Nightlies go unless the selection or an instance still plays them. Worlds stay.
pub fn after_update(version: &str) {
let list = instances::list();
if list
.iter()
.any(|i| i.id == INSTANCE && i.version.as_deref().is_some_and(|v| date_of(v).is_some()))
{
let _ = instances::edit(INSTANCE, |meta| meta.version = Some(version.to_owned()));
}
let selected = backend::Settings::load().selected;
let pinned: Vec<String> = instances::list()
.into_iter()
.next()
.filter(|r| r.prerelease)
}
/// The nightly to install: `latest` when it isn't installed yet.
pub fn update(latest: Option<Release>, installed: &[String]) -> Option<Release> {
latest.filter(|r| r.asset.is_some() && !installed.contains(&version_of(r)))
}
/// Installs `release` (the current nightly) as its own version, points the Nightly instance at
/// it (making the instance the first time) and removes older nightly versions. Returns the
/// instance.
pub fn install(release: &Release, progress: impl Fn(f32)) -> Result<Instance, String> {
let sha256 = crate::signed::nightly_digest(
ASSET.unwrap_or_default(),
&horizon_release_sign::trusted_keys(),
backend::fetch_small,
)?;
let version = version_of(release);
backend::install_as(release, &version, &sha256, progress)?;
let instance = instance_on(&version)?;
for old in backend::installed() {
if horizon_release_sign::is_nightly(&old) && old != version {
let _ = backend::remove(&old);
}
.filter_map(|i| i.version)
.collect();
for old in stale(&backend::installed(), version, selected.as_deref(), &pinned) {
let _ = backend::remove(&old);
}
Ok(instance)
}
/// The Nightly instance, made empty the first time (with the player's options and offline name),
/// playing `version`.
fn instance_on(version: &str) -> Result<Instance, String> {
if instances::find(INSTANCE).is_err() {
instances::create(NAME, None)?;
}
instances::edit(INSTANCE, |meta| meta.version = Some(version.to_owned()))
/// The installed Nightlies to remove once `current` is installed: every other one that is neither
/// selected nor pinned by an instance.
fn stale(
installed: &[String],
current: &str,
selected: Option<&str>,
pinned: &[String],
) -> Vec<String> {
installed
.iter()
.filter(|v| date_of(v).is_some() && *v != current && Some(v.as_str()) != selected)
.filter(|v| !pinned.contains(v))
.cloned()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn release(name: &str, date: &str, asset: bool) -> Release {
fn release(name: &str, date: &str) -> Release {
Release {
tag: NIGHTLY.into(),
name: name.into(),
body: String::new(),
date: date.into(),
asset: asset.then(|| ("https://example/horizon.zip".into(), 1)),
asset: Some(("https://example/horizon.zip".into(), 1)),
prerelease: true,
nightly: None,
}
}
#[test]
fn a_nightly_installs_under_its_date_and_commit() {
let r = release("Nightly 2026-10-28 · 0c02067", "2026-10-28", true);
let r = release("Nightly 2026-10-28 · 0c02067", "2026-10-28");
assert_eq!(version_of(&r), "nightly-2026-10-28-0c02067");
let untitled = release("Something else", "2026-10-29", true);
let untitled = release("Something else", "2026-10-29");
assert_eq!(version_of(&untitled), "nightly-2026-10-29");
// Never a release version: the release channel's checks and lists leave it out.
assert!(!backend::is_game_tag(&version_of(&r)));
assert_eq!(date_of(&version_of(&r)), Some("2026-10-28"));
assert_eq!(date_of("v0.2.95"), None);
assert_eq!(date_of("nightly"), None);
assert!(horizon_release_sign::is_nightly(&version_of(&r)));
}
#[test]
fn only_a_new_nightly_with_a_build_here_is_an_update() {
let r = release("Nightly 2026-10-28 · 0c02067", "2026-10-28", true);
let name = |r: Option<Release>| r.map(|r| r.name);
fn only_unused_older_nightlies_are_removed() {
let installed = [
"nightly-2026-10-29-aaaaaaa",
"nightly-2026-10-28-bbbbbbb",
"nightly-2026-10-28-ccccccc",
"nightly-2026-10-27-ddddddd",
"v0.2.95",
]
.map(String::from);
let pinned = ["nightly-2026-10-27-ddddddd".to_string()];
assert_eq!(
name(update(Some(r.clone()), &["v0.2.80".into()])),
Some(r.name.clone())
stale(
&installed,
"nightly-2026-10-29-aaaaaaa",
Some("nightly-2026-10-28-ccccccc"),
&pinned
),
["nightly-2026-10-28-bbbbbbb"]
);
let installed = ["nightly-2026-10-28-0c02067".to_string(), "v0.2.80".into()];
assert!(update(Some(r), &installed).is_none());
let no_build = release("Nightly 2026-10-29 · 1a2b3c4", "2026-10-29", false);
assert!(update(Some(no_build), &installed).is_none());
assert!(update(None, &installed).is_none());
}
#[test]
fn only_the_nightly_pre_release_is_read() {
let json = |tag: &str, pre: bool| {
serde_json::json!({"tag_name": tag, "name": "Nightly 2026-10-28 · 0c02067",
"prerelease": pre, "published_at": "2026-10-28T20:40:00Z",
"assets": [{"name": ASSET.unwrap_or("none"), "browser_download_url": "u", "size": 1}]})
};
assert!(parse(&json("nightly", true)).is_some());
assert!(parse(&json("nightly", false)).is_none());
assert!(parse(&json("v0.2.81", true)).is_none());
}
}
+6 -5
View File
@@ -30,14 +30,15 @@ pub fn digest(
horizon_release_sign::signed_digest(tag, asset, sums.as_deref(), signature.as_deref(), keys)
}
/// The signed SHA-256 of `asset` in the current nightly build (the `nightly` pre-release): its
/// checksums must be signed for the nightly channel (`horizon_release_sign::nightly_digest`).
/// The signed SHA-256 of `asset` in the Nightly published under `tag` (`nightly`, or a dated
/// `nightly-<date>`): its checksums must be signed for the nightly channel
/// (`horizon_release_sign::nightly_digest`), whatever tag serves them.
pub fn nightly_digest(
tag: &str,
asset: &str,
keys: &[PublicKey],
fetch: impl Fn(&str) -> Result<Option<Vec<u8>>, String>,
) -> Result<String, String> {
let tag = horizon_release_sign::NIGHTLY;
let sums = fetch(&asset_url(tag, SUMS))?;
let signature = fetch(&asset_url(tag, SIGNATURE))?
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned());
@@ -131,7 +132,7 @@ mod tests {
};
let nightly = serving(horizon_release_sign::NIGHTLY);
assert_eq!(
nightly_digest("horizon.zip", &keys, &nightly),
nightly_digest("nightly", "horizon.zip", &keys, &nightly),
Ok(sha256_hex(b"game"))
);
for tag in ["v0.2.81", "nightly", "nightly-2026-10-27"] {
@@ -142,6 +143,6 @@ mod tests {
}
let release = serving("v0.2.81");
assert!(digest("v0.2.81", "horizon.zip", &keys, &release).is_ok());
assert!(nightly_digest("horizon.zip", &keys, &release).is_err());
assert!(nightly_digest("nightly", "horizon.zip", &keys, &release).is_err());
}
}