Launcher import: share the copy with native builds, test the checks

- native_mods::copy_base is the base-folder copy both a native build and an
  import start from; carried_over stays private.
- is_game (platform magic and the mod API mark) and base_for (with --base)
  are pure and tested: a program without the mark, a --base that is not an
  installed release or Nightly, and a failed import that must leave nothing.
- One reader for a plain file and a zip entry, refused past 1 GiB.
- install_as runs the same after-install step as an import.
- The Versions page draws native and imported builds with one helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
some-du6eandClaude Opus 5.5 committed 2026-11-01 20:01:44 -06:00
1 parent a9a86950bd
commit 7990e63009
4 files changed
+197 -163

No files matched your search

+3 -5
View File
@@ -822,8 +822,7 @@ pub(crate) fn install_as(
fs::rename(&top, &target).map_err(|e| e.to_string())?;
fs::write(target.join(COMPLETE), "").map_err(|e| e.to_string())?;
let _ = fs::remove_dir_all(&partial);
reuse_prepared(&root, tag);
complete_assets(tag);
reuse_prepared_for(tag);
progress(1.0);
Ok(())
}
@@ -1163,9 +1162,8 @@ fn reuse_prepared(versions: &Path, tag: &str) -> Option<String> {
None
}
/// What an install does after its folder is in place, for a version put there another way (an
/// imported build, `imported`): another installed version's resources if it has none, and
/// whatever its resources lack.
/// What an install does once its folder is in place (an imported build's too, `imported`):
/// another installed version's resources if it has none, and whatever its resources lack.
pub(crate) fn reuse_prepared_for(tag: &str) {
reuse_prepared(&versions_dir(), tag);
complete_assets(tag);
+120 -89
View File
@@ -61,13 +61,29 @@ fn official(version: &str) -> bool {
backend::is_game_tag(version) || crate::nightly::date_of(version).is_some()
}
/// The installed version an import takes its game files from: `selected` when Horizon published
/// it, else the newest installed release or Nightly (`installed` is newest first).
pub fn base_for(installed: &[String], selected: Option<&str>) -> Option<String> {
selected
.filter(|s| official(s) && installed.iter().any(|t| t == s))
.map(str::to_owned)
.or_else(|| installed.iter().find(|t| official(t)).cloned())
/// The installed version an import takes its game files from: `requested` (`--base`), else
/// `selected` when Horizon published it, else the newest installed release or Nightly.
fn base_for(
installed: &[String],
requested: Option<&str>,
selected: Option<&str>,
) -> Result<String, String> {
let usable = |v: &str| official(v) && installed.iter().any(|t| t == v);
match requested {
Some(b) if usable(b) => Ok(b.to_owned()),
Some(b) => Err(format!("{b} is not an installed release or Nightly.")),
None => {
selected
.filter(|s| usable(s))
.map(str::to_owned)
.or_else(|| native_mods::newest_installed(installed).cloned())
.ok_or_else(|| {
tr("Install a release or Nightly first: an imported build plays with its game \
files.")
.to_string()
})
}
}
}
/// Installs the game program at `path` (or the one inside a zip there) as a new version on the
@@ -79,22 +95,11 @@ pub fn import(path: &Path, base: Option<&str>) -> Result<String, String> {
|n| n.to_string_lossy().into_owned(),
);
let program = read_program(path, &file)?;
if !runs_here(&program) || horizon_mods::api_level::read_mark(&program).is_none() {
return Err(trf(
"{file} is not a Horizon game for this computer.",
&[("file", &file)],
));
if !is_game(&program) {
return Err(not_a_game(&file));
}
let installed = backend::installed();
let selected = backend::Settings::load().selected;
let base = match base {
Some(b) if official(b) && installed.iter().any(|t| t == b) => b.to_owned(),
Some(b) => return Err(format!("{b} is not an installed release or Nightly.")),
None => base_for(&installed, selected.as_deref()).ok_or_else(|| {
tr("Install a release or Nightly first: an imported build plays with its game files.")
.to_string()
})?,
};
let base = base_for(&backend::installed(), base, selected.as_deref())?;
let date = chrono::Local::now().format("%Y-%m-%d").to_string();
let tag = install_in(&backend::version_dir(""), &base, &program, &file, &date)?;
backend::reuse_prepared_for(&tag);
@@ -105,42 +110,32 @@ pub fn import(path: &Path, base: Option<&str>) -> Result<String, String> {
Ok(tag)
}
fn not_a_game(file: &str) -> String {
trf(
"{file} is not a Horizon game for this computer.",
&[("file", file)],
)
}
/// The program at `path`: the file itself when it is named as the game is (`horizon-client`,
/// "horizon-client (1).exe" as a browser saves a second copy), or from a zip (a GitHub Actions
/// artifact), its one entry named as the game is on this platform (`CLIENT`), in any folder.
/// The launcher and horizon-ctl carry the mod API mark too: the name tells them apart.
fn read_program(path: &Path, file: &str) -> Result<Vec<u8>, String> {
let not_a_game = || {
trf(
"{file} is not a Horizon game for this computer.",
&[("file", file)],
)
};
let open = fs::File::open(path).map_err(|e| format!("{file}: {e}"))?;
let Ok(mut zip) = zip::ZipArchive::new(open) else {
let size = fs::metadata(path)
.map_err(|e| format!("{file}: {e}"))?
.len();
if !file.to_ascii_lowercase().starts_with("horizon-client") || size > MAX_PROGRAM {
return Err(not_a_game());
let open = || fs::File::open(path).map_err(|e| format!("{file}: {e}"));
let Ok(mut zip) = zip::ZipArchive::new(open()?) else {
if !file.to_ascii_lowercase().starts_with("horizon-client") {
return Err(not_a_game(file));
}
return fs::read(path).map_err(|e| format!("{file}: {e}"));
return read_at_most(open()?, file);
};
for i in 0..zip.len() {
let mut entry = zip.by_index(i).map_err(|e| format!("{file}: {e}"))?;
let entry = zip.by_index(i).map_err(|e| format!("{file}: {e}"))?;
let named = entry
.enclosed_name()
.is_some_and(|p| p.file_name() == Some(std::ffi::OsStr::new(CLIENT)));
if named && entry.is_file() {
let mut program = Vec::new();
(&mut entry)
.take(MAX_PROGRAM + 1)
.read_to_end(&mut program)
.map_err(|e| format!("{file}: {e}"))?;
if program.len() as u64 > MAX_PROGRAM {
return Err(not_a_game());
}
return Ok(program);
return read_at_most(entry, file);
}
}
Err(trf(
@@ -149,9 +144,22 @@ fn read_program(path: &Path, file: &str) -> Result<Vec<u8>, String> {
))
}
/// Whether `program` starts as a program of this platform does: PE on Windows, Mach-O on macOS,
/// ELF elsewhere. A Linux build picked on Windows would only fail to start.
fn runs_here(program: &[u8]) -> bool {
/// All of `from`, refused past [`MAX_PROGRAM`] rather than cut off.
fn read_at_most(from: impl Read, file: &str) -> Result<Vec<u8>, String> {
let mut program = Vec::new();
from.take(MAX_PROGRAM + 1)
.read_to_end(&mut program)
.map_err(|e| format!("{file}: {e}"))?;
if program.len() as u64 > MAX_PROGRAM {
return Err(not_a_game(file));
}
Ok(program)
}
/// Whether `program` is a Horizon game for this computer: it starts as this platform's programs
/// do (PE on Windows, Mach-O on macOS, ELF elsewhere: a Linux build picked on Windows would
/// only fail to start) and carries the mod API mark every game since 0.2.69 does.
fn is_game(program: &[u8]) -> bool {
let magic: &[&[u8]] = if cfg!(windows) {
&[b"MZ"]
} else if cfg!(target_os = "macos") {
@@ -161,6 +169,7 @@ fn runs_here(program: &[u8]) -> bool {
&[b"\x7fELF"]
};
magic.iter().any(|m| program.starts_with(m))
&& horizon_mods::api_level::read_mark(program).is_some()
}
/// Installs `program` in `versions` as a copy of installed version `base` with it in place of
@@ -197,48 +206,37 @@ fn install_in(
let dir = versions.join(&tag);
let partial = versions.join(format!(".{tag}.partial"));
let _ = fs::remove_dir_all(&partial);
fs::create_dir_all(&partial).map_err(|e| e.to_string())?;
let record = serde_json::json!({ "base": base, "file": file, "sha256": sha256 });
let copied = fill(&versions.join(base), &partial, program, &record).and_then(|()| {
let _ = fs::remove_dir_all(&dir);
fs::rename(&partial, &dir)?;
fs::write(dir.join(".installed"), "")
});
if let Err(e) = copied {
let made = fs::create_dir_all(&partial)
.map_err(|e| e.to_string())
.and_then(|()| native_mods::copy_base(&versions.join(base), &partial, Target::Client))
.and_then(|()| finish(&partial, &dir, program, &record).map_err(|e| e.to_string()));
if let Err(e) = made {
let _ = fs::remove_dir_all(&partial);
let _ = fs::remove_dir_all(&dir);
return Err(trf(
"Could not import the build: {error}",
&[("error", &e.to_string())],
));
return Err(trf("Could not import the build: {error}", &[("error", &e)]));
}
Ok(tag)
}
/// Fills the new version's folder `to`: version folder `base`'s files but its game and its
/// record (`native_mods::carried_over`, cloned where the disk can), `program` as the game, and
/// what it was made from.
fn fill(base: &Path, to: &Path, program: &[u8], record: &serde_json::Value) -> std::io::Result<()> {
for entry in fs::read_dir(base)?.flatten() {
let name = entry.file_name();
if !native_mods::carried_over(&name, Target::Client) || name == RECORD {
continue;
}
let target = to.join(&name);
if entry.path().is_dir() {
backend::copy_dir(&entry.path(), &target)?;
} else {
horizon_prepare::clone_or_copy(&entry.path(), &target)?;
fs::set_permissions(&target, entry.metadata()?.permissions())?;
}
}
fs::write(to.join(CLIENT), program)?;
/// Puts `program` and its record into the copied folder `partial`, then moves it into place as
/// installed version `dir`.
fn finish(
partial: &Path,
dir: &Path,
program: &[u8],
record: &serde_json::Value,
) -> std::io::Result<()> {
fs::write(partial.join(CLIENT), program)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(to.join(CLIENT), fs::Permissions::from_mode(0o755))?;
fs::set_permissions(partial.join(CLIENT), fs::Permissions::from_mode(0o755))?;
}
fs::write(to.join(RECORD), record.to_string())
fs::write(partial.join(RECORD), record.to_string())?;
let _ = fs::remove_dir_all(dir);
fs::rename(partial, dir)?;
fs::write(dir.join(".installed"), "")
}
#[cfg(test)]
@@ -319,11 +317,36 @@ mod tests {
let _ = fs::remove_dir_all(versions);
}
#[test]
fn a_failed_import_leaves_nothing_behind() {
let versions = dir("failed");
// A folder from an import that never finished is not that program's version.
let built = program(b"build");
let unfinished = install_in(&versions, "v0.2.95", &built, CLIENT, "2026-10-01");
assert!(unfinished.is_err(), "no base to copy");
assert_eq!(fs::read_dir(&versions).unwrap().count(), 0);
let short: String = Sha256::digest(&built)[..4]
.iter()
.map(|b| format!("{b:02x}"))
.collect();
fs::create_dir_all(versions.join(format!("imported-2026-10-01-{}", &short[..7]))).unwrap();
assert!(install_in(&versions, "v0.2.95", &built, CLIENT, "2026-10-02").is_err());
let _ = fs::remove_dir_all(versions);
}
#[test]
fn only_a_horizon_game_for_this_computer_is_imported() {
assert!(runs_here(&program(b"")));
let other = if cfg!(windows) { b"\x7fELF" } else { b"MZ\0\0" };
assert!(!runs_here(other));
assert!(is_game(&program(b"")));
// Another program of this platform, without the game's mark.
assert!(!is_game(&program(b"")[..program(b"").len() - 27]));
let other = if cfg!(windows) {
&b"\x7fELF"[..]
} else {
&b"MZ"[..]
};
assert!(!is_game(
&[other, &horizon_mods::api_level::MARK[..]].concat()
));
let d = dir("zip");
let zip_path = d.join("horizon-client-windows-x86_64.zip");
let mut zip = zip::ZipWriter::new(fs::File::create(&zip_path).unwrap());
@@ -362,16 +385,24 @@ mod tests {
"native-c9d474d-0123456789ab".to_string(),
"imported-2026-10-31-abcdef0".to_string(),
];
let newest = Ok("nightly-2026-11-01-c9d474d".to_string());
assert_eq!(
base_for(&installed, Some("v0.2.95")).as_deref(),
Some("v0.2.95")
base_for(&installed, None, Some("v0.2.95")),
Ok("v0.2.95".to_string())
);
for selected in [None, Some("imported-2026-10-31-abcdef0"), Some("v0.1.0")] {
assert_eq!(
base_for(&installed, selected).as_deref(),
Some("nightly-2026-11-01-c9d474d")
);
assert_eq!(base_for(&installed, None, selected), newest);
}
assert!(base_for(&installed[2..], None, None).is_err());
// --base: an installed release or Nightly, never another local build.
let asked = |b| base_for(&installed, Some(b), Some("v0.2.95"));
assert_eq!(asked("nightly-2026-11-01-c9d474d"), newest);
for b in [
"v0.1.0",
"native-c9d474d-0123456789ab",
"imported-2026-10-31-abcdef0",
] {
assert!(asked(b).is_err(), "{b}");
}
assert_eq!(base_for(&installed[2..], None), None);
}
}
+50 -52
View File
@@ -4124,6 +4124,37 @@ impl Launcher {
.children(error.map(|e| notice(e, &t)))
}
/// The installed builds of one kind made here (`is`: native or imported) under their caption,
/// with a notice above them; None when there are none.
fn local_builds(
&self,
title: &str,
is: fn(&str) -> bool,
notice_: Option<&str>,
on_disk: &dyn Fn(&String) -> Option<u64>,
cx: &mut Context<Self>,
) -> Option<Div> {
let t = self.theme;
let tags: Vec<&String> = self
.installed
.iter()
.chain(&self.removing)
.filter(|v| is(v))
.collect();
(!tags.is_empty()).then(|| {
div()
.flex()
.flex_col()
.gap(px(8.))
.child(caps(tr(title)).text_color(t.muted))
.children(notice_.map(|n| notice(n.to_string(), &t)))
.children(
tags.into_iter()
.map(|tag| self.local_build_row(tag, on_disk, cx)),
)
})
}
fn versions_page(&mut self, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let header = page_header(
@@ -4189,58 +4220,25 @@ impl Launcher {
.child(import),
)
.children(self.import_error.clone().map(|e| notice(e, &t)));
// Native-mods builds are made here, not downloaded: they are listed apart from the releases.
let native: Vec<String> = self
.installed
.iter()
.chain(&self.removing)
.filter(|v| native_mods::is_native(v))
.cloned()
.collect();
let page = page.when(!native.is_empty(), |page| {
page.child(
div()
.flex()
.flex_col()
.gap(px(8.))
.child(caps(tr("Native mod builds")).text_color(t.muted))
.children(
native
.iter()
.map(|tag| self.local_build_row(tag, &on_disk, cx)),
),
)
});
// Imported builds: programs the player picked, which nothing signed.
let imports: Vec<String> = self
.installed
.iter()
.chain(&self.removing)
.filter(|v| imported::is_imported(v))
.cloned()
.collect();
let page = page.when(!imports.is_empty(), |page| {
page.child(
div()
.flex()
.flex_col()
.gap(px(8.))
.child(caps(tr("Imported builds")).text_color(t.muted))
.child(notice(
tr(
"Unsigned: you picked these programs yourself, and Horizon can't \
check where they came from. Updates never replace them.",
)
.to_string(),
&t,
))
.children(
imports
.iter()
.map(|tag| self.local_build_row(tag, &on_disk, cx)),
),
)
});
// Builds made here, not downloaded, are listed apart from the releases.
let page = page
.children(self.local_builds(
"Native mod builds",
native_mods::is_native,
None,
&on_disk,
cx,
))
.children(self.local_builds(
"Imported builds",
imported::is_imported,
Some(tr(
"Unsigned: you picked these programs yourself, and Horizon can't \
check where they came from. Updates never replace them.",
)),
&on_disk,
cx,
));
let releases = match &self.releases {
Load::Loading => return page.child(centered(tr("Loading releases…"), &t)),
Load::Failed(e) => {
+24 -17
View File
@@ -796,7 +796,7 @@ fn about_in(dir: &Path) -> Option<About> {
}
/// The newest installed release or Nightly in `installed` (newest first).
fn newest_installed(installed: &[String]) -> Option<&String> {
pub(crate) fn newest_installed(installed: &[String]) -> Option<&String> {
installed
.iter()
.find(|t| backend::is_game_tag(t) || t.starts_with("nightly-"))
@@ -1725,21 +1725,7 @@ fn install(
let partial = target.directory(&format!(".{version}.partial"));
let _ = fs::remove_dir_all(&partial);
fs::create_dir_all(&partial).map_err(|e| e.to_string())?;
for entry in fs::read_dir(&from).map_err(|e| e.to_string())?.flatten() {
let name = entry.file_name();
if !carried_over(&name, target) {
continue;
}
let to = partial.join(&name);
let copied = if entry.path().is_dir() {
backend::copy_dir(&entry.path(), &to)
} else {
// A clone is a new file: horizon-ctl and horizon-server keep their permissions.
horizon_prepare::clone_or_copy(&entry.path(), &to)
.and_then(|()| fs::set_permissions(&to, entry.metadata()?.permissions()))
};
copied.map_err(|e| format!("Could not copy {}: {e}", entry.path().display()))?;
}
copy_base(&from, &partial, target)?;
let installed = partial.join(target.binary());
fs::copy(binary, &installed).map_err(|e| format!("The built game: {e}"))?;
fs::write(partial.join("native-mods.json"), about.to_string()).map_err(|e| e.to_string())?;
@@ -1753,12 +1739,33 @@ fn install(
fs::write(dir.join(".installed"), "").map_err(|e| e.to_string())
}
/// Copies version folder `from` into `to` but for what a new build of `target` makes its own
/// ([`carried_over`]), cloned where the disk can. Also an imported build's start (`imported`).
pub(crate) fn copy_base(from: &Path, to: &Path, target: Target) -> Result<(), String> {
for entry in fs::read_dir(from).map_err(|e| e.to_string())?.flatten() {
let name = entry.file_name();
if !carried_over(&name, target) {
continue;
}
let to = to.join(&name);
let copied = if entry.path().is_dir() {
backend::copy_dir(&entry.path(), &to)
} else {
// A clone is a new file: horizon-ctl and horizon-server keep their permissions.
horizon_prepare::clone_or_copy(&entry.path(), &to)
.and_then(|()| fs::set_permissions(&to, entry.metadata()?.permissions()))
};
copied.map_err(|e| format!("Could not copy {}: {e}", entry.path().display()))?;
}
Ok(())
}
/// Whether a file of the version a build starts from goes into the new build. The game (the
/// target's own binary), its record, and the mods' post-build data (`native-data/`, which the
/// new build's steps write again) are the new build's own: a mod turned off leaves none of its
/// data behind. A server build is the headless engine: it keeps neither the game's own binary
/// nor any `horizon-engine` the base shipped, which would run unmodded beside its own.
pub(crate) fn carried_over(name: &std::ffi::OsStr, target: Target) -> bool {
fn carried_over(name: &std::ffi::OsStr, target: Target) -> bool {
let mut own: Vec<String> = ["Horizon.app", "native-mods.json", "native-data"]
.iter()
.map(|s| s.to_string())