Merge pull request #3596 from horizon-rust/fix/launcher-native-mods-auto-on

🤖 Agent: Ludovic's decision 10-29 (native mods on when one is installed, notice once). Diff reviewed; verify green; build matrix 40848959744 green on Windows, macOS and Linux.
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-10-29 10:35:00 +01:00
commit 13d7d9995d
10 files changed
+170 -9

No files matched your search

+3 -2
View File
@@ -40,8 +40,9 @@ mod shows one.
## What the launcher does
In the window: install a native mod from the Mods page, turn on Settings → Game → **Native mods (experimental)**,
then **Build**. The launcher names the mods, warns in red, and builds only from "I trust these mods: build"; the
In the window: install a native mod from the Mods page. The first one shows a notice once (the first build downloads
Rust and can take 20 to 30 minutes; later builds are faster) and turns on Settings → Game → **Native mods
(experimental)**, which stays off for players who never install one; `mods install` does the same. Then **Build**. The launcher names the mods, warns in red, and builds only from "I trust these mods: build"; the
row shows each step, the error if one fails, and the build the instance plays. From the command line:
`horizon-launcher native-build [FOLDER] [--instance NAME] --trust-native-code`. FOLDER is a bundle, an unpacked
`.hnmod`, or a folder of them; without it, the native mods turned on in the instance. It is not an MCP tool:
+4 -2
View File
@@ -181,9 +181,11 @@
"These mods will run with full access to your computer": "Diese Mods erhalten vollen Zugriff auf deinen Computer",
"They can read, change or delete your files, and use your accounts and network, like any program you run. Build them only if you trust their authors: the Mods page names them.": "Sie können deine Dateien lesen, ändern oder löschen und deine Konten sowie dein Netzwerk nutzen wie jedes andere Programm, das du ausführst. Kompiliere sie nur, wenn du ihren Autoren vertraust: Die Mods-Seite nennt sie.",
"Mods: {mods}": "Mods: {mods}",
"The build compiles the game: about ten minutes and a few GB of disk the first time, a few minutes after that.": "Der Build kompiliert das Spiel: beim ersten Mal etwa zehn Minuten und einige GB Speicherplatz, danach einige Minuten.",
"The build compiles the game: 20 to 30 minutes and a few GB of disk the first time (it downloads Rust), a few minutes after that.": "Der Build kompiliert das Spiel: beim ersten Mal 20 bis 30 Minuten und einige GB Speicherplatz (er lädt Rust herunter), danach wenige Minuten.",
"Delete old versions automatically": "Alte Versionen automatisch löschen",
"Keeps the {count} newest, the one in use and the ones your instances use. Deleted versions install again from the Versions page.": "Behält die {count} neuesten, die verwendete und die von deinen Instanzen genutzten. Gelöschte Versionen lassen sich auf der Seite Versionen wieder installieren.",
"Installed versions use {size}.": "Installierte Versionen belegen {size}.",
"{size} on disk": "{size} auf dem Datenträger"
"{size} on disk": "{size} auf dem Datenträger",
"Install and turn on native mods": "Installieren und native Mods einschalten",
"Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Native Mods werden in ein eigenes Spiel kompiliert. Wenn du eine installierst, werden native Mods eingeschaltet: Danach baust du das Spiel damit unter Einstellungen > Native Mods. Der erste Build lädt Rust herunter und kann 20 bis 30 Minuten dauern; spätere Builds sind schneller."
}
+3 -1
View File
@@ -185,7 +185,9 @@
"These mods will run with full access to your computer": "Ces mods auront un accès complet à votre ordinateur",
"They can read, change or delete your files, and use your accounts and network, like any program you run. Build them only if you trust their authors: the Mods page names them.": "Ils peuvent lire, modifier ou supprimer vos fichiers, et utiliser vos comptes et votre réseau, comme tout programme que vous lancez. Ne les compilez que si vous faites confiance à leurs auteurs : la page Mods les nomme.",
"Mods: {mods}": "Mods : {mods}",
"The build compiles the game: about ten minutes and a few GB of disk the first time, a few minutes after that.": "La compilation construit le jeu : une dizaine de minutes et quelques Go de disque la première fois, quelques minutes ensuite.",
"The build compiles the game: 20 to 30 minutes and a few GB of disk the first time (it downloads Rust), a few minutes after that.": "La compilation construit le jeu : 20 à 30 minutes et quelques Go de disque la première fois (elle télécharge Rust), quelques minutes ensuite.",
"Install and turn on native mods": "Installer et activer les mods natifs",
"Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Les mods natifs sont compilés dans un jeu à part. En installer un active les mods natifs : vous compilez ensuite le jeu avec lui dans Paramètres > Mods natifs. La première compilation télécharge Rust et peut prendre 20 à 30 minutes ; les suivantes sont plus rapides.",
"Delete…": "Supprimer…",
"Keep": "Garder",
"Move to Trash": "Mettre à la corbeille",
+3 -1
View File
@@ -185,7 +185,9 @@
"These mods will run with full access to your computer": "Эти моды получат полный доступ к вашему компьютеру",
"They can read, change or delete your files, and use your accounts and network, like any program you run. Build them only if you trust their authors: the Mods page names them.": "Они могут читать, изменять и удалять ваши файлы, использовать ваши аккаунты и сеть, как любая запущенная вами программа. Собирайте их, только если доверяете авторам: они указаны на странице «Моды».",
"Mods: {mods}": "Моды: {mods}",
"The build compiles the game: about ten minutes and a few GB of disk the first time, a few minutes after that.": "Сборка компилирует игру: около десяти минут и несколько ГБ на диске в первый раз, потом несколько минут.",
"The build compiles the game: 20 to 30 minutes and a few GB of disk the first time (it downloads Rust), a few minutes after that.": "Сборка компилирует игру: 20–30 минут и несколько ГБ на диске в первый раз (она скачивает Rust), затем несколько минут.",
"Install and turn on native mods": "Установить и включить нативные моды",
"Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Нативные моды компилируются в отдельную игру. Установка такого мода включает нативные моды: затем вы собираете с ним игру в «Настройки > Нативные моды». Первая сборка скачивает Rust и может занять 20–30 минут; следующие быстрее.",
"Delete…": "Удалить…",
"Keep": "Оставить",
"Move to Trash": "В корзину",
+6 -2
View File
@@ -2865,10 +2865,14 @@ 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 Native mods (experimental) on (`native_mods`): mixin mods compiled into
/// a game of their own. Off by default.
/// Native mods (experimental) are on (`native_mods`): mixin mods compiled into a game of
/// their own. Off until the player installs a native mod (`native_mods::turns_on`), or turns
/// them on in Settings.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub native_mods: bool,
/// The player has read what installing a native mod starts (`native_mods::needs_notice`).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub native_mods_noticed: bool,
/// Developer options (hidden: seven clicks on the sidebar's launcher version reveal them):
/// offline names play without an account that owns the game, in the window only
/// (`account::Accounts::play_as_with`, `account::launch_for_game`). Never a download: the game still
+8
View File
@@ -60,6 +60,14 @@ pub fn known_index() -> Option<Arc<Index>> {
.map(|(_, index)| index.clone())
}
/// Whether the registry's project `id` is a native mod, by the index already read (never the
/// network); false when none was read.
pub fn is_native_mod(id: &str) -> bool {
known_index()
.and_then(|index| Some(index.project(id)?.kind == index::Kind::NativeMod))
.unwrap_or(false)
}
/// The index: the one fetched in the last ten minutes, else fetched again (and kept in the game
/// folder), else the copy kept last time.
pub fn index() -> Result<Arc<Index>, String> {
+12
View File
@@ -4,6 +4,7 @@ use clap::{Parser, Subcommand};
use horizon_launcher::account::{self, Accounts};
use horizon_launcher::backend::{self, Release};
use horizon_launcher::instances;
use horizon_launcher::native_mods;
use horizon_launcher::server_list;
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
@@ -2111,6 +2112,17 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
println!(" not ported: {s}");
}
}
// A native mod turns Native mods on, with the window's notice, once.
let mut settings = backend::Settings::load();
let list = installed::Installed::load(&home).entries;
if !settings.native_mods && native_mods::turns_on(false, &list) {
if !settings.native_mods_noticed {
eprintln!("{}", native_mods::NOTICE);
}
settings.native_mods = true;
settings.native_mods_noticed = true;
settings.save();
}
}
ModsCmd::Update { project, check, .. } => {
let list = installed::Installed::load(&home).entries;
+1 -1
View File
@@ -3244,7 +3244,7 @@ impl Launcher {
.child(div().text_color(t.danger).child(tr("These mods will run with full access to your computer").to_string()))
.child(tr("They can read, change or delete your files, and use your accounts and network, like any program you run. Build them only if you trust their authors: the Mods page names them.").to_string())
.child(trf("Mods: {mods}", &[("mods", &list)]))
.child(tr("The build compiles the game: about ten minutes and a few GB of disk the first time, a few minutes after that.").to_string());
.child(tr("The build compiles the game: 20 to 30 minutes and a few GB of disk the first time (it downloads Rust), a few minutes after that.").to_string());
let mut buttons = div().flex().gap(px(8.)).mt(px(4.));
if mods.is_ok() {
buttons = buttons.child(
+74
View File
@@ -83,6 +83,8 @@ pub(super) struct Mods {
message: Option<Result<String, String>>,
updates: HashMap<(SourceId, String), String>,
confirm_remove: Option<String>,
/// A native mod's Install waiting for the one-time notice (`native_mods::needs_notice`).
native_notice: Option<(SourceId, String, Option<Project>)>,
/// Java mods' checks, by source and project.
pub(super) checks: HashMap<(SourceId, String), Checking>,
/// Horizon registry projects' trust and capabilities, by id ([`horizon::trust_and_capabilities`]).
@@ -164,6 +166,7 @@ impl Mods {
world: None,
busy: None,
message: None,
native_notice: None,
updates: HashMap::new(),
confirm_remove: None,
checks: HashMap::new(),
@@ -269,6 +272,7 @@ impl Launcher {
let mut to_check = Vec::new();
let mut loaded = Vec::new();
let mut done = false;
let mut native_on = false;
while let Ok(event) = self.mods.rx.try_recv() {
changed = true;
let m = &mut self.mods;
@@ -299,6 +303,7 @@ impl Launcher {
m.busy = None;
m.message = Some(result);
m.installed = installed::Installed::load(&m.home()).entries;
native_on = native_mods::turns_on(native_on, &m.installed);
m.installed_list.reset();
if modpack {
// The Modpacks page reads its list again.
@@ -326,6 +331,12 @@ impl Launcher {
}
}
}
// A native mod installed: Native mods turn on, so Settings offers its Build.
if native_on && !self.settings.native_mods {
self.settings.native_mods = true;
self.settings.native_mods_noticed = true;
self.settings.save();
}
// `HORIZON_LAUNCHER_PROJECT=<slug>` (with `HORIZON_LAUNCHER_PAGE=mods`): opens that
// project's page once a search finds it, for screenshots.
if self.mods.browse.open.is_none() {
@@ -578,6 +589,18 @@ impl Launcher {
if self.mods.busy.is_some() {
return;
}
// The first native mod: what it starts, before anything is installed.
let first_native = source == SourceId::Horizon
&& native_mods::needs_notice(
self.settings.native_mods,
self.settings.native_mods_noticed,
)
&& horizon::is_native_mod(&id);
if first_native {
self.mods.native_notice = Some((source, id, known));
return;
}
self.mods.native_notice = None;
self.mods.message = None;
self.mods.busy = Some((id.clone(), format!("Asking {}…", source.label()), 0.));
self.mods.updates.remove(&(source, id.clone()));
@@ -849,6 +872,7 @@ impl Launcher {
.child(header)
.child(div().flex().flex_col().child(kinds).child(body))
};
let body = body.children(self.native_notice(cx));
if let Some(project) = self.mods_project_page(cx) {
return page(body.children(self.mods_busy_plate()).child(project));
}
@@ -926,6 +950,56 @@ impl Launcher {
}
/// The install under way and the last install's answer.
/// The one-time notice before the first native mod is installed: what the build takes. Its
/// button installs the mod; Native mods then turn on.
fn native_notice(&self, cx: &mut Context<Self>) -> Option<Div> {
self.mods.native_notice.as_ref()?;
let t = self.theme;
Some(
plate(&t)
.flex()
.flex_col()
.gap(px(10.))
.p(px(16.))
.text_size(px(theme::UI.px))
.text_color(t.ink)
.child(tr(native_mods::NOTICE).to_string())
.child(
div()
.flex()
.gap(px(8.))
.child(
button(
"native-notice-install",
"Install and turn on native mods",
Kind::Primary,
&t,
)
.on_click(cx.listener(
|this, _: &ClickEvent, _, cx| {
this.settings.native_mods_noticed = true;
this.settings.save();
if let Some((source, id, known)) =
this.mods.native_notice.take()
{
this.mods_install(source, id, known);
}
cx.notify();
},
)),
)
.child(
button("native-notice-cancel", "Cancel", Kind::Secondary, &t).on_click(
cx.listener(|this, _: &ClickEvent, _, cx| {
this.mods.native_notice = None;
cx.notify();
}),
),
),
),
)
}
pub(super) fn mods_busy_plate(&self) -> Vec<Div> {
let t = self.theme;
let mut out = Vec::new();
+56
View File
@@ -33,6 +33,26 @@ pub const MIXINS_REV: &str = "0d6b103c70633d8fd9443d8d0acd3aadaacedb06";
const PROFILE: &str = "release";
const FEATURES: &str = "horizon-client/mods";
/// Whether Native mods are on after an install into a game folder whose list is `installed`:
/// installing a native mod turns them on, so the player only has to build. A player who never
/// installs one keeps them off; one already on stays on.
pub fn turns_on(on: bool, installed: &[crate::browser::installed::Entry]) -> bool {
on || installed.iter().any(|e| {
e.file
.strip_prefix(INSTALLED)
.is_some_and(|rest| rest.starts_with('/'))
})
}
/// Whether installing a native mod shows its notice first (what the first build takes): once,
/// for a player whose Native mods are still off.
pub fn needs_notice(on: bool, noticed: bool) -> bool {
!on && !noticed
}
/// The notice before the first native mod is installed.
pub const NOTICE: &str = "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.";
/// Whether an installed version is a native-mods build.
pub fn is_native(version: &str) -> bool {
version.starts_with(PREFIX)
@@ -778,6 +798,42 @@ pub fn prune(keep: &str) {
mod tests {
use super::*;
/// Ludovic's decision (2026-10-29): installing a native mod turns Native mods on, after a
/// notice shown once; players who never install one keep them off.
#[test]
fn installing_a_native_mod_turns_native_mods_on() {
let entry = |file: &str| {
serde_json::from_value::<crate::browser::installed::Entry>(serde_json::json!({
"source": "horizon", "project": "p", "slug": "p", "title": "P", "kind": "mod",
"version": "1", "number": "1", "file": file, "checksum": "", "page": ""
}))
.unwrap()
};
let plain = [
entry("horizon-mods/zoom.hmod"),
entry("resourcepacks/x.zip"),
];
assert!(!turns_on(false, &[]));
assert!(!turns_on(false, &plain), "no native mod: they stay off");
assert!(!turns_on(
false,
&[entry("horizon-native-mods-old/x.hnmod")]
));
let native = [
plain[0].clone(),
entry("horizon-native-mods/veinminer.hnmod"),
];
assert!(turns_on(false, &native));
assert!(turns_on(true, &plain), "on stays on");
// The notice: before the first one only.
assert!(needs_notice(false, false));
assert!(!needs_notice(false, true));
assert!(!needs_notice(true, false));
// Settings without the fields read as off and not noticed.
let s: backend::Settings = serde_json::from_str("{}").unwrap();
assert!(!s.native_mods && !s.native_mods_noticed);
}
fn dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("horizon-native-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);