mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8307404dc3 | ||
|
|
6f19499acf | ||
|
|
2325ba52d4 | ||
|
|
c794b87841 |
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# The public keys prebuilt native-mod builds are signed with (crates/horizon-release-sign/src/native_build.rs,
|
||||
# #11859): one base64 Ed25519 key per line. The launcher builds these in and refuses a prebuilt build no key here
|
||||
# signed. They are not release keys: the private keys exist only as the NATIVE_BUILD_SIGNING_KEY secret(s) of
|
||||
# horizon-rust/horizon-mods' `native-builds` environment, where the registry compiles other people's code.
|
||||
# Changing this file needs Ludovic's review (.github/CODEOWNERS).
|
||||
Z6IAbJ0E8fIGYddlbe1FYfYNpzzOseREOdNbANWirKA=
|
||||
@@ -16,6 +16,8 @@
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
|
||||
|
||||
pub mod native_build;
|
||||
|
||||
/// The checksum list's asset name.
|
||||
pub const SUMS: &str = "SHA256SUMS";
|
||||
/// Its signatures' asset name.
|
||||
@@ -386,7 +388,13 @@ impl SigningKey {
|
||||
|
||||
/// A signature line for SHA256SUMS.sig.
|
||||
pub fn sign(&self, tag: &str, sums: &[u8]) -> String {
|
||||
STANDARD.encode(self.0.sign(&message(tag, sums)).as_ref())
|
||||
self.sign_bytes(&message(tag, sums))
|
||||
}
|
||||
|
||||
/// A base64 signature line over `message` as it is (a prebuilt native build's
|
||||
/// [`native_build::message`]).
|
||||
pub fn sign_bytes(&self, message: &[u8]) -> String {
|
||||
STANDARD.encode(self.0.sign(message).as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
//! Prebuilt native-mod builds (#11859): the game compiled for one exact set of registry native
|
||||
//! mods, by horizon-mods' CI, signed there and checked by the launcher before it installs one.
|
||||
//!
|
||||
//! A build is named by its [`Request`]: the target, the Horizon commit it builds on (the Nightly or
|
||||
//! release the instance plays) and each mod's id, version and the SHA-256 of its registry file. The
|
||||
//! launcher computes the request's [`Request::key`] from what it has installed; CI computes it from
|
||||
//! the workflow's own inputs, never from what a build wrote. The file for a target is
|
||||
//! [`archive`] in the release [`release_tag`] of horizon-mods, and its signature [`signature`]
|
||||
//! beside it: one base64 Ed25519 signature per line over [`message`], so a signature can't pass for
|
||||
//! another set's file, another target's, a release's (`horizon release <tag>`) or a Nightly's.
|
||||
//!
|
||||
//! The keys are not the release keys: `native-build-keys.txt` lists them, and their private halves
|
||||
//! are the `NATIVE_BUILD_SIGNING_KEY` secret(s) of horizon-mods' `native-builds` environment. The
|
||||
//! registry compiles other people's code there, so a key it holds must never sign a game release.
|
||||
|
||||
use crate::{PublicKey, Sha256, parse_keys};
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use ring::signature::{ED25519, UnparsedPublicKey};
|
||||
|
||||
const KEYS: &str = include_str!("../native-build-keys.txt");
|
||||
|
||||
/// The first line of a request's text: a new layout gets a new number, and so new keys.
|
||||
const REQUEST_HEADER: &str = "horizon native build request 1";
|
||||
|
||||
/// The public keys prebuilt native builds are signed with: those in `native-build-keys.txt`. A
|
||||
/// file that doesn't parse gives none, so every prebuilt build is refused (a test keeps it valid).
|
||||
pub fn trusted_keys() -> Vec<PublicKey> {
|
||||
parse_keys(KEYS).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// One mod of a build: its registry id and version, and the SHA-256 of its registry file.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Mod {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
/// What a prebuilt build is made of. Two requests with the same mods in another order are the same
|
||||
/// request: the mods are sorted by id.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Request {
|
||||
/// The Rust target triple the game is compiled for.
|
||||
pub target: String,
|
||||
/// The commit of the Nightly or release the build starts from (40 hex digits).
|
||||
pub base: String,
|
||||
pub mods: Vec<Mod>,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
/// A checked request, its mods sorted by id.
|
||||
pub fn new(target: &str, base: &str, mut mods: Vec<Mod>) -> Result<Self, String> {
|
||||
if !is_target(target) {
|
||||
return Err(format!("{target:?} is not a target triple"));
|
||||
}
|
||||
if !is_hex(base, 40) {
|
||||
return Err(format!("{base:?} is not a commit (40 hex digits)"));
|
||||
}
|
||||
if mods.is_empty() {
|
||||
return Err("a native build needs at least one mod".into());
|
||||
}
|
||||
for m in &mods {
|
||||
if !is_id(&m.id) {
|
||||
return Err(format!("{:?} is not a mod id", m.id));
|
||||
}
|
||||
if !is_version(&m.version) {
|
||||
return Err(format!("{}: {:?} is not a version", m.id, m.version));
|
||||
}
|
||||
if !is_hex(&m.sha256, 64) {
|
||||
return Err(format!("{}: {:?} is not a SHA-256", m.id, m.sha256));
|
||||
}
|
||||
}
|
||||
mods.sort();
|
||||
if let Some(pair) = mods.windows(2).find(|w| w[0].id == w[1].id) {
|
||||
return Err(format!("{} is listed twice", pair[0].id));
|
||||
}
|
||||
Ok(Self {
|
||||
target: target.into(),
|
||||
base: base.to_ascii_lowercase(),
|
||||
mods: mods
|
||||
.into_iter()
|
||||
.map(|m| Mod {
|
||||
sha256: m.sha256.to_ascii_lowercase(),
|
||||
..m
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The text the key hashes: a header, the target, the base, then one line per mod.
|
||||
pub fn text(&self) -> String {
|
||||
let mut text = format!(
|
||||
"{REQUEST_HEADER}\ntarget {}\nbase {}\n",
|
||||
self.target, self.base
|
||||
);
|
||||
for m in &self.mods {
|
||||
text.push_str(&format!("mod {} {} {}\n", m.id, m.version, m.sha256));
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// The request's SHA-256, in hex: what names the build.
|
||||
pub fn key(&self) -> String {
|
||||
let mut hash = Sha256::default();
|
||||
hash.update(self.text().as_bytes());
|
||||
hash.hex()
|
||||
}
|
||||
}
|
||||
|
||||
/// The horizon-mods release holding the builds made on commit `base`.
|
||||
pub fn release_tag(base: &str) -> String {
|
||||
format!("native-builds-{}", &base[..base.len().min(12)])
|
||||
}
|
||||
|
||||
/// The archive of build `key` for `target` (its first 16 hex digits name it).
|
||||
pub fn archive(key: &str, target: &str) -> String {
|
||||
format!("{}-{target}.tar.gz", &key[..key.len().min(16)])
|
||||
}
|
||||
|
||||
/// The signature file beside [`archive`].
|
||||
pub fn signature(key: &str, target: &str) -> String {
|
||||
format!("{}-{target}.sig", &key[..key.len().min(16)])
|
||||
}
|
||||
|
||||
/// What a build's signature covers: the whole key, the target, then the archive's SHA-256 and
|
||||
/// name as `sha256sum` writes them. CI signs this text with OpenSSL.
|
||||
pub fn message(key: &str, target: &str, sha256: &str, asset: &str) -> Vec<u8> {
|
||||
format!("horizon native build {key} {target}\n{sha256} {asset}\n").into_bytes()
|
||||
}
|
||||
|
||||
/// Checks that one of `keys` signed archive `asset` with SHA-256 `sha256` as build `key` for
|
||||
/// `target`. `signatures` is the signature file's text.
|
||||
pub fn verify(
|
||||
key: &str,
|
||||
target: &str,
|
||||
sha256: &str,
|
||||
asset: &str,
|
||||
signatures: &str,
|
||||
keys: &[PublicKey],
|
||||
) -> Result<(), String> {
|
||||
if keys.is_empty() {
|
||||
return Err("This launcher has no key for prebuilt native builds built in.".into());
|
||||
}
|
||||
let message = message(key, target, &sha256.to_ascii_lowercase(), asset);
|
||||
let signed = signatures
|
||||
.lines()
|
||||
.filter_map(|line| STANDARD.decode(line.trim()).ok())
|
||||
.any(|signature| {
|
||||
keys.iter().any(|k| {
|
||||
UnparsedPublicKey::new(&ED25519, k)
|
||||
.verify(&message, &signature)
|
||||
.is_ok()
|
||||
})
|
||||
});
|
||||
if signed {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"{asset}'s signature doesn't match Horizon's key for prebuilt native builds, so it was \
|
||||
not installed."
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn is_hex(text: &str, len: usize) -> bool {
|
||||
text.len() == len && text.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// A registry id: what `horizon_mods::index::valid_id` takes, and no space or line break.
|
||||
fn is_id(text: &str) -> bool {
|
||||
(1..=64).contains(&text.len())
|
||||
&& text
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
|
||||
}
|
||||
|
||||
fn is_version(text: &str) -> bool {
|
||||
(1..=64).contains(&text.len()) && text.bytes().all(|b| b.is_ascii_graphic())
|
||||
}
|
||||
|
||||
/// `x86_64-unknown-linux-gnu`: lowercase words joined by hyphens, at least two of them.
|
||||
fn is_target(text: &str) -> bool {
|
||||
text.len() <= 64
|
||||
&& text.split('-').count() >= 2
|
||||
&& text.split('-').all(|part| {
|
||||
!part.is_empty()
|
||||
&& part
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::SigningKey;
|
||||
|
||||
const BASE: &str = "656064105c0e1fbd0a8f1d6e5f5d3a8b3c2e1f00";
|
||||
|
||||
fn mod_(id: &str, version: &str, byte: char) -> Mod {
|
||||
Mod {
|
||||
id: id.into(),
|
||||
version: version.into(),
|
||||
sha256: byte.to_string().repeat(64),
|
||||
}
|
||||
}
|
||||
|
||||
fn request() -> Request {
|
||||
Request::new(
|
||||
"x86_64-unknown-linux-gnu",
|
||||
BASE,
|
||||
vec![mod_("zoom", "2.0.0", 'b'), mod_("minimap", "1.4.0", 'a')],
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A key computed by hand, so the registry's CI (Python) and the launcher name a build alike.
|
||||
#[test]
|
||||
fn the_key_is_the_sha256_of_the_documented_text() {
|
||||
let r = request();
|
||||
assert_eq!(
|
||||
r.text(),
|
||||
format!(
|
||||
"horizon native build request 1\ntarget x86_64-unknown-linux-gnu\nbase {BASE}\n\
|
||||
mod minimap 1.4.0 {}\nmod zoom 2.0.0 {}\n",
|
||||
"a".repeat(64),
|
||||
"b".repeat(64)
|
||||
)
|
||||
);
|
||||
// python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())'
|
||||
assert_eq!(
|
||||
r.key(),
|
||||
"c07fffc296cf14c5c496a016a930f84b0d0ac028a63c667c2bdf47657788445d"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_and_case_do_not_change_the_key() {
|
||||
let shuffled = Request::new(
|
||||
"x86_64-unknown-linux-gnu",
|
||||
&BASE.to_ascii_uppercase(),
|
||||
vec![mod_("minimap", "1.4.0", 'A'), mod_("zoom", "2.0.0", 'B')],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(shuffled.key(), request().key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_input_changes_the_key() {
|
||||
let key = request().key();
|
||||
let other = |target: &str, base: &str, mods: Vec<Mod>| {
|
||||
Request::new(target, base, mods).unwrap().key()
|
||||
};
|
||||
let mods = || vec![mod_("zoom", "2.0.0", 'b'), mod_("minimap", "1.4.0", 'a')];
|
||||
assert_ne!(other("x86_64-pc-windows-gnu", BASE, mods()), key);
|
||||
assert_ne!(
|
||||
other("x86_64-unknown-linux-gnu", &"1".repeat(40), mods()),
|
||||
key
|
||||
);
|
||||
assert_ne!(
|
||||
other(
|
||||
"x86_64-unknown-linux-gnu",
|
||||
BASE,
|
||||
vec![mod_("zoom", "2.0.1", 'b'), mod_("minimap", "1.4.0", 'a')]
|
||||
),
|
||||
key
|
||||
);
|
||||
assert_ne!(
|
||||
other(
|
||||
"x86_64-unknown-linux-gnu",
|
||||
BASE,
|
||||
vec![mod_("zoom", "2.0.0", 'c'), mod_("minimap", "1.4.0", 'a')]
|
||||
),
|
||||
key
|
||||
);
|
||||
assert_ne!(
|
||||
other(
|
||||
"x86_64-unknown-linux-gnu",
|
||||
BASE,
|
||||
vec![mod_("zoom", "2.0.0", 'b')]
|
||||
),
|
||||
key
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_that_could_forge_another_line_is_refused() {
|
||||
let base = "x86_64-unknown-linux-gnu";
|
||||
assert!(Request::new(base, BASE, vec![]).is_err());
|
||||
assert!(Request::new("linux", BASE, vec![mod_("a", "1", 'a')]).is_err());
|
||||
assert!(Request::new("x86 64-linux", BASE, vec![mod_("a", "1", 'a')]).is_err());
|
||||
assert!(Request::new(base, "abc", vec![mod_("a", "1", 'a')]).is_err());
|
||||
assert!(Request::new(base, BASE, vec![mod_("a b", "1", 'a')]).is_err());
|
||||
assert!(Request::new(base, BASE, vec![mod_("a", "1\nmod x", 'a')]).is_err());
|
||||
assert!(Request::new(base, BASE, vec![mod_("a", "1", 'g')]).is_err());
|
||||
assert!(Request::new(base, BASE, vec![mod_("a", "1", 'a'), mod_("a", "2", 'b')]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signed_build_verifies_and_nothing_else_does() {
|
||||
let (signer, _) = SigningKey::generate().unwrap();
|
||||
let (stranger, _) = SigningKey::generate().unwrap();
|
||||
let r = request();
|
||||
let key = r.key();
|
||||
let target = &r.target;
|
||||
let asset = archive(&key, target);
|
||||
let sha = "c".repeat(64);
|
||||
let sign = |k: &SigningKey, msg: Vec<u8>| k.sign_bytes(&msg) + "\n";
|
||||
let good = sign(&signer, message(&key, target, &sha, &asset));
|
||||
let keys = [signer.public()];
|
||||
assert_eq!(verify(&key, target, &sha, &asset, &good, &keys), Ok(()));
|
||||
// Another key, another archive, another set or target, no key at all.
|
||||
let theirs = sign(&stranger, message(&key, target, &sha, &asset));
|
||||
assert!(verify(&key, target, &sha, &asset, &theirs, &keys).is_err());
|
||||
assert!(verify(&key, target, &"d".repeat(64), &asset, &good, &keys).is_err());
|
||||
let other = "e".repeat(64);
|
||||
assert!(verify(&other, target, &sha, &asset, &good, &keys).is_err());
|
||||
assert!(verify(&key, "aarch64-apple-darwin", &sha, &asset, &good, &keys).is_err());
|
||||
assert!(verify(&key, target, &sha, &asset, &good, &[]).is_err());
|
||||
// A release's signature over the same bytes is no build's.
|
||||
let release = signer.sign(&key, format!("{sha} {asset}\n").as_bytes()) + "\n";
|
||||
assert!(verify(&key, target, &sha, &asset, &release, &keys).is_err());
|
||||
// One line of several is enough (a key being replaced signs beside the old one).
|
||||
let both = theirs + &good;
|
||||
assert_eq!(verify(&key, target, &sha, &asset, &both, &keys), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_built_in_keys_file_parses() {
|
||||
assert!(parse_keys(KEYS).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assets_are_named_by_the_keys_first_16_digits() {
|
||||
let key = request().key();
|
||||
assert_eq!(release_tag(BASE), "native-builds-656064105c0e");
|
||||
assert_eq!(
|
||||
archive(&key, "aarch64-apple-darwin"),
|
||||
format!("{}-aarch64-apple-darwin.tar.gz", &key[..16])
|
||||
);
|
||||
assert_eq!(
|
||||
signature(&key, "aarch64-apple-darwin"),
|
||||
format!("{}-aarch64-apple-darwin.sig", &key[..16])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! A prebuilt native build signed as horizon-mods' workflow signs it (tools/ci/sign_native_build.sh,
|
||||
//! OpenSSL), then checked as the launcher checks it (`native_build::verify`).
|
||||
use horizon_release_sign::native_build::{Mod, Request, archive, signature, verify};
|
||||
use horizon_release_sign::{SigningKey, parse_keys, sha256_hex};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
|
||||
/// An OpenSSL 3 (the runner's), or None on a machine without one (macOS ships LibreSSL).
|
||||
fn openssl() -> Option<String> {
|
||||
[
|
||||
"openssl",
|
||||
"/opt/homebrew/opt/openssl@3/bin/openssl",
|
||||
"/usr/local/opt/openssl@3/bin/openssl",
|
||||
]
|
||||
.into_iter()
|
||||
.find(|o| {
|
||||
Command::new(o)
|
||||
.arg("version")
|
||||
.output()
|
||||
.is_ok_and(|v| v.stdout.starts_with(b"OpenSSL 3"))
|
||||
})
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
fn request() -> Request {
|
||||
Request::new(
|
||||
"x86_64-unknown-linux-gnu",
|
||||
&"6".repeat(40),
|
||||
vec![Mod {
|
||||
id: "minimap".into(),
|
||||
version: "1.4.0".into(),
|
||||
sha256: "a".repeat(64),
|
||||
}],
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A folder with the build's archive, as the build job leaves it.
|
||||
fn folder(name: &str, request: &Request) -> (PathBuf, PathBuf) {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-native-sign-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join(archive(&request.key(), &request.target));
|
||||
std::fs::write(&file, b"the game with the minimap").unwrap();
|
||||
(dir, file)
|
||||
}
|
||||
|
||||
fn sign(key: &str, target: &str, file: &Path, env: &[(&str, &str)], keys: &Path) -> Output {
|
||||
let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tools/ci/sign_native_build.sh");
|
||||
let mut cmd = Command::new("bash");
|
||||
cmd.arg(script)
|
||||
.args([key, target])
|
||||
.arg(file)
|
||||
.env_remove("NATIVE_BUILD_SIGNING_KEY")
|
||||
.env_remove("NATIVE_BUILD_SIGNING_KEY_NEXT")
|
||||
.env("OPENSSL", openssl().unwrap())
|
||||
.env("NATIVE_BUILD_KEYS", keys);
|
||||
for (k, v) in env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
cmd.output().unwrap()
|
||||
}
|
||||
|
||||
fn read_signature(dir: &Path, r: &Request) -> String {
|
||||
std::fs::read_to_string(dir.join(signature(&r.key(), &r.target))).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_workflows_signature_is_what_the_launcher_checks() {
|
||||
// The script runs on the Linux runner of horizon-mods' sign job.
|
||||
if cfg!(windows) || openssl().is_none() {
|
||||
eprintln!("skipped: tools/ci/sign_native_build.sh is tested on Linux (bash, OpenSSL 3)");
|
||||
return;
|
||||
}
|
||||
let r = request();
|
||||
let (dir, file) = folder("secret", &r);
|
||||
let sha = sha256_hex(b"the game with the minimap");
|
||||
let name = archive(&r.key(), &r.target);
|
||||
let (old, old_secret) = SigningKey::generate().unwrap();
|
||||
let (new, new_secret) = SigningKey::generate().unwrap();
|
||||
let keys = dir.join("keys.txt");
|
||||
std::fs::write(
|
||||
&keys,
|
||||
format!(
|
||||
"# listed\n{}\n{}\n",
|
||||
old.public_base64(),
|
||||
new.public_base64()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let out = sign(
|
||||
&r.key(),
|
||||
&r.target,
|
||||
&file,
|
||||
&[
|
||||
("NATIVE_BUILD_SIGNING_KEY", &old_secret),
|
||||
("NATIVE_BUILD_SIGNING_KEY_NEXT", &new_secret),
|
||||
],
|
||||
&keys,
|
||||
);
|
||||
let printed = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert!(out.status.success(), "{printed}");
|
||||
assert!(!printed.contains(&old_secret) && !printed.contains(&new_secret));
|
||||
let sig = read_signature(&dir, &r);
|
||||
assert_eq!(sig.lines().count(), 2);
|
||||
let listed = parse_keys(&std::fs::read_to_string(&keys).unwrap()).unwrap();
|
||||
for key in [&listed[..1], &listed[1..]] {
|
||||
assert_eq!(verify(&r.key(), &r.target, &sha, &name, &sig, key), Ok(()));
|
||||
}
|
||||
// The same signature for another set, target or file is refused.
|
||||
let other = "0".repeat(64);
|
||||
assert!(verify(&other, &r.target, &sha, &name, &sig, &listed).is_err());
|
||||
assert!(verify(&r.key(), "aarch64-apple-darwin", &sha, &name, &sig, &listed).is_err());
|
||||
assert!(verify(&r.key(), &r.target, &other, &name, &sig, &listed).is_err());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_launchers_would_refuse_or_a_misnamed_archive_signs_nothing() {
|
||||
if cfg!(windows) || openssl().is_none() {
|
||||
eprintln!("skipped: tools/ci/sign_native_build.sh is tested on Linux (bash, OpenSSL 3)");
|
||||
return;
|
||||
}
|
||||
let r = request();
|
||||
let (dir, file) = folder("refused", &r);
|
||||
let (_, secret) = SigningKey::generate().unwrap();
|
||||
let keys = dir.join("keys.txt");
|
||||
std::fs::write(&keys, "# none yet\n").unwrap();
|
||||
let env = [("NATIVE_BUILD_SIGNING_KEY", secret.as_str())];
|
||||
// Not listed in the keys file.
|
||||
assert!(
|
||||
!sign(&r.key(), &r.target, &file, &env, &keys)
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
// No key at all.
|
||||
assert!(
|
||||
!sign(&r.key(), &r.target, &file, &[], &keys)
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
// The archive named for another key than the one signed.
|
||||
let ephemeral = [("EPHEMERAL", "yes")];
|
||||
assert!(
|
||||
!sign(&"0".repeat(64), &r.target, &file, &ephemeral, &keys)
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
// A dry run signs with a throwaway key, whose public half it leaves for the check.
|
||||
let out = sign(&r.key(), &r.target, &file, &ephemeral, &keys);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let public = parse_keys(&std::fs::read_to_string(dir.join("ephemeral.pub")).unwrap()).unwrap();
|
||||
let sha = sha256_hex(b"the game with the minimap");
|
||||
let name = archive(&r.key(), &r.target);
|
||||
let sig = read_signature(&dir, &r);
|
||||
assert_eq!(
|
||||
verify(&r.key(), &r.target, &sha, &name, &sig, &public),
|
||||
Ok(())
|
||||
);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -111,12 +111,14 @@ Native mods (docs/native-mods.md): compile mixin bundles on the Horizon revision
|
||||
|
||||
- `FOLDER`: A mod folder (horizon-mixin.toml, or an unpacked .hnmod), or a folder of them (default: the native mods installed and turned on in the instance)
|
||||
- `--server-output`: Export mode: build a standalone dedicated-server engine into this folder instead of an instance's build. Needs a mod FOLDER; no instance is read or changed, and no launcher config is written
|
||||
- `--prebuilt-out`: Prebuilt mode, for the registry's CI (#11859): build every .hnmod file in FOLDER as a player's launcher would, and write the program, its native-mods.json and request.txt into this new folder instead of installing it. The base needs no prepared game files
|
||||
- `--instance`: The instance whose installed native mods to build, and which then plays the build (default: the selected one)
|
||||
- `--update`: Move to the newest installed release or Nightly instead of the Horizon the instance plays (a full rebuild)
|
||||
- `--trust-native-code`: You read the mods' source and trust it with full access to this computer
|
||||
- `--allow-version-mismatch`: Build mods made for different Horizon versions together anyway, on the newest of them
|
||||
- `--ignore-pins`: Build every mod on the commit of the version the build starts from, even the mods that pin another Horizon commit (they may not apply or compile there)
|
||||
- `--profile` (performance, dev, release): The build profile: performance (the default: incremental, quick to rebuild), dev (quickest to rebuild a mod, slow game) or release (thin LTO: slowest to build, slightly faster game). Default: the native-profile setting
|
||||
- `--prebuilt` (auto, never, only): The registry's prebuilt build of the instance's mods (#11859): auto (download it when there is one, else compile), never (always compile), only (never compile). Default: the native-prebuilt setting (on: auto; off, the default for now: never)
|
||||
|
||||
MCP: none: the player's switch.
|
||||
|
||||
@@ -237,7 +239,7 @@ Show the launcher's settings, or set one (the Settings page): selected-version T
|
||||
|
||||
horizon-launcher settings [KEY] [VALUE]
|
||||
|
||||
- `KEY` (selected-version, minecraft-dir, server-dir, install-new-versions, prune-old-versions, keep-versions, opening-animation, game-architecture, proxy, while-playing, language, accent, background, native-profile, native-cache-gib)
|
||||
- `KEY` (selected-version, minecraft-dir, server-dir, install-new-versions, prune-old-versions, keep-versions, opening-animation, game-architecture, proxy, while-playing, language, accent, background, native-profile, native-cache-gib, native-prebuilt)
|
||||
- `VALUE`: The new value (with KEY)
|
||||
|
||||
MCP: `settings`.
|
||||
|
||||
@@ -1922,6 +1922,48 @@ whole file, **Open full log** opens it outside the launcher, **Show in folder**
|
||||
refresh, not a live stream: a running build's later output appears only on the next refresh. Nothing is
|
||||
uploaded; the log stays on the player's computer.
|
||||
|
||||
## Prebuilt builds
|
||||
|
||||
Most players can't wait 30 to 60 minutes for a first build, so the registry builds sets of its native mods itself
|
||||
and signs them (#11859). With `horizon-launcher settings native-prebuilt on` (off by default until the registry's
|
||||
signing pipeline has been checked end to end; turning it on for everyone is a separate change), before compiling an
|
||||
instance's mods, the launcher looks for a ready-made build of exactly that set:
|
||||
|
||||
- **Only registry files.** Every native mod turned on in the instance must be a file the registry's index lists, by
|
||||
its SHA-256, and none may have been taken down (`revoked.toml`). A mod installed from GitHub or by hand is built
|
||||
here, and so is a build forced off its pins, a `dev` profile, or a FOLDER given on the command line.
|
||||
- **The request.** The target (the Rust triple the launcher builds for), the commit of the release or Nightly the
|
||||
instance plays, and each mod's id, version and file SHA-256, sorted by id
|
||||
(`horizon_release_sign::native_build::Request`). Its SHA-256 is the build's key.
|
||||
- **Where.** The release `native-builds-<commit12>` of horizon-rust/horizon-mods, assets `<key16>-<target>.sig`
|
||||
and `<key16>-<target>.tar.gz`, fetched by direct link (no GitHub API call). No signature there: no build of this
|
||||
set yet.
|
||||
- **Checked before anything is written** (`native_prebuilt::check`):
|
||||
- one of the keys in `crates/horizon-release-sign/native-build-keys.txt` signed `horizon native build <key>
|
||||
<target>`, the archive's SHA-256 and its name. These keys are not the release keys: the registry compiles
|
||||
other people's code, so its key can sign nothing else;
|
||||
- the archive holds only `horizon-client`, `native-mods.json` and `request.txt`;
|
||||
- its `request.txt` is this request, and its record builds no mod the request doesn't name.
|
||||
- **Installed** as a build of these mods is (`native-<commit>-<key12>`, a copy of the base version's folder with the
|
||||
downloaded program), its `native-mods.json` naming the build it came from (`prebuilt`). The instance plays it, and
|
||||
the Mods page reads it as up to date.
|
||||
- **Otherwise it compiles**, as before: the row says "No ready-made build of these mods yet: building them on this
|
||||
computer.", and a build that fails a check is logged (`native/build.log`) and compiled instead.
|
||||
|
||||
The warning before a build stays the same: a prebuilt build skips the compiler, not the decision to trust the mods.
|
||||
`horizon-launcher native-build --prebuilt auto|never|only` picks per build (`only` fails rather than compile);
|
||||
`horizon-launcher settings native-prebuilt on|off|default` sets the default (`default` and `off` always compile, for
|
||||
now).
|
||||
|
||||
**How the registry makes them** (horizon-mods' `native-build.yml`): one run per set, on GitHub's runners. The
|
||||
launcher's own pipeline builds it:
|
||||
`horizon-launcher native-build --prebuilt-out DIR --version <Nightly> --profile release --trust-native-code FOLDER`
|
||||
builds every `.hnmod` in FOLDER as a player's launcher would (the same pins, hook points and left-out mods), on the
|
||||
Nightly's source, without the player's game files, and writes the program, its `native-mods.json` and `request.txt`
|
||||
into DIR. A set with a post-build step is never exported, because that step runs on the player's own game files.
|
||||
The build job holds no secret and shares no cache. A separate job signs, with the key the `native-builds`
|
||||
environment holds, the request the plan computed, never one a build reports.
|
||||
|
||||
## Rebuilds stay on the instance's Horizon
|
||||
|
||||
A build starts from the version the instance plays (`native_mods::base_for`). When that is a native build, the
|
||||
|
||||
@@ -37,6 +37,7 @@ and some are waiting for Ludovic:
|
||||
| Fork pull requests need approval | **Live**: every outside contributor's workflows wait for a maintainer's approval. |
|
||||
| Private vulnerability reporting | **Live** (see the end of this page). |
|
||||
| Signed checksums on releases | **Live** for new releases (#1011): the `release` environment holds the key, and its public half is in `release-keys.txt` (#1023). Releases from before then are unsigned. |
|
||||
| Signed prebuilt native-mod builds | **Being built** (#11859): horizon-mods' `native-builds` environment (deployable from `main` only) holds `NATIVE_BUILD_SIGNING_KEY`, a key of its own, never the release key, because the registry compiles other people's code there. Its public half is in `native-build-keys.txt`. Each signature covers the build's request key (target, base commit, each mod's file SHA-256), its target and the archive's SHA-256 (`horizon_release_sign::native_build`), so it can't pass for another set's file or for a release. `tools/ci/sign_native_build.sh` signs with OpenSSL. |
|
||||
| Launchers and servers checking signatures | **Not yet**: #1014 waits as a draft until a signed stable game release and a signed launcher release exist. Until then they trust GitHub's checksum, as before. |
|
||||
| Smoke test and release-blocker before publishing | **Live**: since 2026-10-23 every release is the latest at once (one channel, Ludovic's decision), so it is published only after its smoke test passed and while no `release-blocker` is open. The 6-hour beta of the old Alpha, then stable flow (#1012) is gone. |
|
||||
| Reports marked suspicious | **Partly**: the scan of Discord for agents does it. Kira's issues do it once its pull request merges. |
|
||||
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Laai 'n klaargemaakte bou van hierdie mods af: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Nog geen klaargemaakte bou van hierdie mods nie: hulle word op hierdie rekenaar gebou."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "تنزيل نسخة جاهزة من هذه التعديلات: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "لا توجد نسخة جاهزة من هذه التعديلات بعد: يجري بناؤها على هذا الحاسوب."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Bu modların hazır yığımı yüklənir: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Bu modların hələ hazır yığımı yoxdur: onlar bu kompüterdə yığılır."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Был модтарҙың әҙер йыйылмаһы йөкләнә: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Был модтарҙың әлегә әҙер йыйылмаһы юҡ: улар ошо компьютерҙа йыйыла."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Спампоўваецца гатовая зборка гэтых модаў: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Гатовай зборкі гэтых модаў яшчэ няма: яны збіраюцца на гэтым камп'ютары."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Изтегляне на готова компилация на тези модове: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Все още няма готова компилация на тези модове: компилират се на този компютър."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "O pellgargañ ur savadur prest eus ar mods-mañ: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "N'eus savadur prest ebet eus ar mods-mañ c'hoazh: savet e vezont war an urzhiataer-mañ."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Preuzimanje gotove verzije ovih modova: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Još nema gotove verzije ovih modova: grade se na ovom računaru."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "S'està baixant una compilació ja feta d'aquests mods: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Encara no hi ha cap compilació feta d'aquests mods: es compilen en aquest ordinador."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Stahování hotového sestavení těchto módů: {done} %",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Hotové sestavení těchto módů zatím není: sestavují se na tomto počítači."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Ҫак модсен хатӗр пуххине тиенӗ: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Ҫак модсен хатӗр пухху халлӗхе ҫук: вӗсене ҫак компьютерта пуҫтараҫҫӗ."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Yn llwytho adeiladwaith parod o'r modiau hyn i lawr: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Does dim adeiladwaith parod o'r modiau hyn eto: maen nhw'n cael eu hadeiladu ar y cyfrifiadur hwn."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Henter en færdigbygget version af disse mods: {done} %",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Der findes endnu ingen færdigbygget version af disse mods: de bygges på denne computer."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Fertiger Build dieser Mods wird heruntergeladen: {done} %",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Noch kein fertiger Build dieser Mods: Sie werden auf diesem Computer gebaut."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Λήψη έτοιμης έκδοσης αυτών των mods: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Δεν υπάρχει ακόμη έτοιμη έκδοση αυτών των mods: χτίζονται σε αυτόν τον υπολογιστή."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Elŝutado de preta konstruaĵo de ĉi tiuj modifoj: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Ankoraŭ ne estas preta konstruaĵo de ĉi tiuj modifoj: ili estas konstruataj en ĉi tiu komputilo."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Descargando una compilación lista de estos mods: {done} %",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Aún no hay una compilación lista de estos mods: se compilan en este ordenador."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Nende modide valmis järgu allalaadimine: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Nende modide valmis järku veel pole: need ehitatakse selles arvutis."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Mod hauen konpilazio prestatua deskargatzen: % {done}",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Oraindik ez dago mod hauen konpilazio prestaturik: ordenagailu honetan konpilatzen dira."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "در حال دریافت نسخهٔ آمادهٔ این مادها: {done}٪",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "هنوز نسخهٔ آمادهای از این مادها نیست: روی همین رایانه ساخته میشوند."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Ladataan näiden modien valmista koontiversiota: {done} %",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Näistä modeista ei ole vielä valmista koontiversiota: ne käännetään tällä tietokoneella."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Dina-download ang handang build ng mga mod na ito: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Wala pang handang build ng mga mod na ito: binubuo ang mga ito sa computer na ito."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Tekur niður eina lidna útgávu av hesum moddunum: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Eingin lidnin útgáva av hesum moddunum enn: tey verða bygd á hesi teldu."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Téléchargement d'une version déjà compilée de ces mods : {done} %",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Pas encore de version déjà compilée de ces mods : ils sont compilés sur cet ordinateur."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Daûr a discjamâ une compilazion pronte di chescj mods: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Ancjemò nissune compilazion pronte di chescj mods: a vegnin compilâts su chest computer."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "In klearmakke build fan dizze mods wurdt download: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Noch gjin klearmakke build fan dizze mods: se wurde op dizze kompjûter boud."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Leagan réamhthógtha de na modhanna seo á íoslódáil: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Níl leagan réamhthógtha de na modhanna seo ann fós: á dtógáil ar an ríomhaire seo."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "A' luchdadh a-nuas togail deiseil de na mods seo: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Chan eil togail deiseil de na mods seo ann fhathast: thèid an togail air a' choimpiutair seo."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Descargando unha compilación xa feita destes mods: {done} %",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Aínda non hai unha compilación feita destes mods: compílanse neste ordenador."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Ke hoʻoiho ʻia nei kekahi kūkulu mākaukau o kēia mau mod: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "ʻAʻohe kūkulu mākaukau o kēia mau mod i kēia manawa: ke kūkulu ʻia nei ma kēia kamepiula."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "מוריד גרסה מוכנה של המודים האלה: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "עדיין אין גרסה מוכנה של המודים האלה: הם נבנים במחשב הזה."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "इन मॉड का तैयार बिल्ड डाउनलोड हो रहा है: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "इन मॉड का अभी कोई तैयार बिल्ड नहीं है: इन्हें इसी कंप्यूटर पर बनाया जा रहा है।"
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Preuzimanje gotove verzije ovih modova: {done} %",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Još nema gotove verzije ovih modova: grade se na ovom računalu."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "A modok kész buildjének letöltése: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Ezekből a modokból még nincs kész build: ezen a számítógépen készülnek el."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Այս մոդերի պատրաստի հավաքի ներբեռնում՝ {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Այս մոդերի պատրաստի հավաք դեռ չկա. դրանք հավաքվում են այս համակարգչում։"
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Mengunduh build siap pakai dari mod ini: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Belum ada build siap pakai dari mod ini: mod dibangun di komputer ini."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Na-ebudata ụdị arụpụtara nke mods ndị a: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Enweghị ụdị arụpụtara nke mods ndị a ugbu a: a na-ewu ha na kọmputa a."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Sæki tilbúna útgáfu af þessum viðbótum: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Engin tilbúin útgáfa af þessum viðbótum enn: þær eru smíðaðar á þessari tölvu."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Download di una build già pronta di queste mod: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Non c'è ancora una build pronta di queste mod: vengono compilate su questo computer."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "これらのMODのビルド済み版をダウンロード中:{done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "これらのMODのビルド済み版はまだありません:このコンピューターでビルドします。"
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "ამ მოდების მზა აწყობის ჩამოტვირთვა: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "ამ მოდების მზა აწყობა ჯერ არ არის: ისინი ამ კომპიუტერზე იწყობა."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Осы модтардың дайын жинағы жүктелуде: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Осы модтардың дайын жинағы әлі жоқ: олар осы компьютерде жиналады."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "ಈ ಮಾಡ್ಗಳ ಸಿದ್ಧ ಬಿಲ್ಡ್ ಡೌನ್ಲೋಡ್ ಆಗುತ್ತಿದೆ: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "ಈ ಮಾಡ್ಗಳ ಸಿದ್ಧ ಬಿಲ್ಡ್ ಇನ್ನೂ ಇಲ್ಲ: ಇವುಗಳನ್ನು ಈ ಕಂಪ್ಯೂಟರ್ನಲ್ಲಿ ನಿರ್ಮಿಸಲಾಗುತ್ತಿದೆ."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "이 모드들의 미리 빌드된 버전을 내려받는 중: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "이 모드들의 미리 빌드된 버전이 아직 없습니다: 이 컴퓨터에서 빌드합니다."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Бул моддордун даяр жыйнагы жүктөлүүдө: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Бул моддордун даяр жыйнагы азырынча жок: алар ушул компьютерде жыйналат."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Compositio parata horum modorum depromitur: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Nondum est compositio parata horum modorum: in hoc computatro componuntur."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "E fäerdege Build vun dëse Mods gëtt erofgelueden: {done} %",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Nach kee fäerdege Build vun dëse Mods: Si ginn op dësem Computer gebaut."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Kant-en-klaore build van dees mods weurt gedownload: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Nog gein kant-en-klaore build van dees mods: ze weure op dees computer gebouwd."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Se descarga una compilazzion pronta de quij mod chì: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Gh'è anmò minga una compilazzion pronta de quij mod chì: hinn compilaa su quell computer chì."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "ກຳລັງດາວໂຫຼດບິວທີ່ກຽມພ້ອມຂອງມອດເຫຼົ່ານີ້: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "ຍັງບໍ່ມີບິວທີ່ກຽມພ້ອມຂອງມອດເຫຼົ່ານີ້: ກຳລັງສ້າງໃນຄອມພິວເຕີນີ້."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Atsisiunčiama paruošta šių modifikacijų versija: {done} %",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Paruoštos šių modifikacijų versijos dar nėra: jos kuriamos šiame kompiuteryje."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Lejupielādē šo modu gatavo būvējumu: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Šo modu gatava būvējuma vēl nav: tie tiek būvēti šajā datorā."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Се презема готова верзија на овие модови: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Сè уште нема готова верзија на овие модови: се градат на овој компјутер."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Эдгээр модын бэлэн хувилбарыг татаж байна: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Эдгээр модын бэлэн хувилбар одоогоор алга: тэдгээрийг энэ компьютер дээр бүтээж байна."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Memuat turun binaan sedia ada bagi mod ini: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Belum ada binaan sedia ada bagi mod ini: mod sedang dibina pada komputer ini."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Qed titniżżel verżjoni lesta ta' dawn il-mods: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Għad m'hemmx verżjoni lesta ta' dawn il-mods: qed jinbnew fuq dan il-kompjuter."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Kant-en-klare build van deze mods wordt gedownload: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Nog geen kant-en-klare build van deze mods: ze worden op deze computer gebouwd."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Laster ned en ferdigbygd versjon av disse modsene: {done} %",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Ingen ferdigbygd versjon av disse modsene ennå: de bygges på denne datamaskinen."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Telecargament d'una compilacion lèsta d'aquestes mods : {done} %",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Pas encara de compilacion lèsta d'aquestes mods : son compilats sus aqueste ordenador."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Pobieranie gotowej kompilacji tych modów: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Nie ma jeszcze gotowej kompilacji tych modów: są budowane na tym komputerze."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Baixando uma compilação pronta destes mods: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Ainda não há compilação pronta destes mods: eles estão sendo compilados neste computador."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "A transferir uma compilação pronta destes mods: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Ainda não há uma compilação pronta destes mods: estão a ser compilados neste computador."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Se descarcă o versiune gata compilată a acestor moduri: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Încă nu există o versiune gata compilată a acestor moduri: sunt compilate pe acest computer."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Загрузка готовой сборки этих модов: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Готовой сборки этих модов пока нет: они собираются на этом компьютере."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Бу модтар бэлэм хомуурулара хачайдана турар: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Бу модтар бэлэм хомуурулара өссө суох: кинилэр бу компьютерга хомуллаллар."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Viežžamin dáid modaid gárvves huksejumi: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Dáin modain ii leat vel gárvves huksejupmi: dat huksejuvvojit dán dihtoris."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Sťahuje sa hotové zostavenie týchto módov: {done} %",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Hotové zostavenie týchto módov zatiaľ nie je: zostavujú sa na tomto počítači."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Prenašanje pripravljene različice teh modov: {done} %",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Pripravljene različice teh modov še ni: gradijo se na tem računalniku."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Waxaa la soo dejinayaa dhisme diyaar ah oo modyadan: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Weli ma jiro dhisme diyaar ah oo modyadan: waxaa lagu dhisayaa kombiyuutarkan."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Po shkarkohet një version i gatshëm i këtyre modeve: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Ende nuk ka version të gatshëm të këtyre modeve: po ndërtohen në këtë kompjuter."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Преузимање готове верзије ових модова: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Још нема готове верзије ових модова: граде се на овом рачунару."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Hämtar en färdigbyggd version av de här moddarna: {done} %",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Ingen färdigbyggd version av de här moddarna ännu: de byggs på den här datorn."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Ściōngano je gotowo kompilacyjo tych modōw: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Niy ma jeszcze gotowyj kompilacyje tych modōw: sōm budowane na tym kōmputrze."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "இந்த மாட்களின் தயார் பில்ட் பதிவிறக்கப்படுகிறது: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "இந்த மாட்களுக்கு இன்னும் தயார் பில்ட் இல்லை: இவை இந்தக் கணினியில் உருவாக்கப்படுகின்றன."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "กำลังดาวน์โหลดบิลด์สำเร็จรูปของม็อดเหล่านี้: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "ยังไม่มีบิลด์สำเร็จรูปของม็อดเหล่านี้: กำลังสร้างบนคอมพิวเตอร์เครื่องนี้"
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Bu modların hazır derlemesi indiriliyor: %{done}",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Bu modların henüz hazır bir derlemesi yok: bu bilgisayarda derleniyorlar."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Бу модларның әзер җыелмасы йөкләнә: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Бу модларның әлегә әзер җыелмасы юк: алар шушы компьютерда җыела."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Завантаження готової збірки цих модів: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Готової збірки цих модів поки немає: вони збираються на цьому комп'ютері."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "ان موڈز کا تیار شدہ بلڈ ڈاؤن لوڈ ہو رہا ہے: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "ان موڈز کا ابھی کوئی تیار شدہ بلڈ نہیں: انہیں اسی کمپیوٹر پر بنایا جا رہا ہے۔"
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Ushbu modlarning tayyor yig'masi yuklab olinmoqda: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Ushbu modlarning tayyor yig'masi hali yo'q: ular shu kompyuterda yig'ilmoqda."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Drio scargar na compilasion pronta de sti mod: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "No ghe xe ncora na compilasion pronta de sti mod: i vien compiłai so sto computer."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Đang tải bản dựng sẵn của các mod này: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Chưa có bản dựng sẵn của các mod này: chúng đang được dựng trên máy tính này."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "מען לאָדט אַראָפּ אַ פֿאַרטיקע בילד פֿון די מאָדס: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "נאָך קיין פֿאַרטיקע בילד פֿון די מאָדס: מען בויט זיי אויף דעם קאָמפּיוטער."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Ń gba ẹ̀dà tí a ti kọ́ sílẹ̀ ti àwọn mod wọ̀nyí: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Kò sí ẹ̀dà tí a ti kọ́ sílẹ̀ ti àwọn mod wọ̀nyí síbẹ̀: à ń kọ́ wọn lórí kọ̀ǹpútà yìí."
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "正在下载这些模组的预构建版本:{done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "这些模组还没有预构建版本:正在这台电脑上构建。"
|
||||
}
|
||||
@@ -1253,5 +1253,7 @@
|
||||
"{host} has no horizon-server. Install it there first, as the hosting guide says.": "{host} has no horizon-server. Install it there first, as the hosting guide says.",
|
||||
"Couldn't connect to {host}: {reason}": "Couldn't connect to {host}: {reason}",
|
||||
"Couldn't connect to {host}.": "Couldn't connect to {host}.",
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}."
|
||||
"horizon-server on {host} failed in {dir}.": "horizon-server on {host} failed in {dir}.",
|
||||
"Downloading a ready-made build of these mods: {done}%": "Kulandwa uhlelo olusenziwe ngomumo lwalawa ma-mod: {done}%",
|
||||
"No ready-made build of these mods yet: building them on this computer.": "Alukho okwamanje uhlelo olusenziwe ngomumo lwalawa ma-mod: akhiwa kule khompyutha."
|
||||
}
|
||||
@@ -4460,6 +4460,10 @@ pub struct Settings {
|
||||
/// automatic cleanup. Installed native versions and player data are not this cache.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub native_cache_gib: Option<u64>,
|
||||
/// Whether a native build first downloads the registry's prebuilt build of the instance's
|
||||
/// mods (`native_prebuilt::Mode::chosen`); unset is off for now (#11859).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub native_prebuilt: Option<bool>,
|
||||
/// An older file's switch for the release profile, read when `native_profile` is unset.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub native_release: bool,
|
||||
|
||||
+93
-1
@@ -9,6 +9,7 @@ use horizon_launcher::i18n::{tr, trf};
|
||||
use horizon_launcher::instances;
|
||||
use horizon_launcher::loaders;
|
||||
use horizon_launcher::native_mods;
|
||||
use horizon_launcher::native_prebuilt;
|
||||
use horizon_launcher::native_status;
|
||||
use horizon_launcher::other_launchers;
|
||||
use horizon_launcher::server_list;
|
||||
@@ -805,6 +806,16 @@ enum Cmd {
|
||||
requires = "folder"
|
||||
)]
|
||||
server_output: Option<PathBuf>,
|
||||
/// Prebuilt mode, for the registry's CI (#11859): build every .hnmod file in FOLDER as a
|
||||
/// player's launcher would, and write the program, its native-mods.json and request.txt
|
||||
/// into this new folder instead of installing it. The base needs no prepared game files
|
||||
#[arg(
|
||||
long = "prebuilt-out",
|
||||
value_name = "DIR",
|
||||
conflicts_with_all = ["instance", "server_output", "update"],
|
||||
requires = "folder"
|
||||
)]
|
||||
prebuilt_out: Option<PathBuf>,
|
||||
/// The instance whose installed native mods to build, and which then plays the build
|
||||
/// (default: the selected one)
|
||||
#[arg(long)]
|
||||
@@ -833,6 +844,11 @@ enum Cmd {
|
||||
/// game). Default: the native-profile setting
|
||||
#[arg(long, value_parser = ["performance", "dev", "release"])]
|
||||
profile: Option<String>,
|
||||
/// The registry's prebuilt build of the instance's mods (#11859): auto (download it when
|
||||
/// there is one, else compile), never (always compile), only (never compile). Default:
|
||||
/// the native-prebuilt setting (on: auto; off, the default for now: never)
|
||||
#[arg(long, value_parser = ["auto", "never", "only"])]
|
||||
prebuilt: Option<String>,
|
||||
},
|
||||
/// Show a release's patch notes (default: the newest)
|
||||
Notes { tag: Option<String> },
|
||||
@@ -1004,7 +1020,7 @@ pub fn command() -> clap::Command {
|
||||
pub const AI_COMMANDS: [&str; 2] = ["mcp", "agent-config"];
|
||||
|
||||
/// The keys `settings` sets.
|
||||
const SETTING_KEYS: [&str; 15] = [
|
||||
const SETTING_KEYS: [&str; 16] = [
|
||||
"selected-version",
|
||||
"minecraft-dir",
|
||||
"server-dir",
|
||||
@@ -1020,6 +1036,7 @@ const SETTING_KEYS: [&str; 15] = [
|
||||
"background",
|
||||
"native-profile",
|
||||
"native-cache-gib",
|
||||
"native-prebuilt",
|
||||
];
|
||||
|
||||
fn on_off(value: &str) -> Result<bool, String> {
|
||||
@@ -1108,6 +1125,12 @@ fn set_setting(s: &mut backend::Settings, key: &str, value: &str) -> Result<(),
|
||||
),
|
||||
};
|
||||
}
|
||||
"native-prebuilt" => {
|
||||
s.native_prebuilt = match value {
|
||||
"default" => None,
|
||||
_ => Some(on_off(value)?),
|
||||
};
|
||||
}
|
||||
"native-profile" => {
|
||||
// "default", as for the other settings, goes back to `performance`.
|
||||
s.native_profile = match value {
|
||||
@@ -1153,6 +1176,7 @@ fn settings_json(s: &backend::Settings) -> serde_json::Value {
|
||||
"background": horizon_launcher::themes::background(&backend::data_dir(), s.background.as_deref()),
|
||||
"native_profile": native_mods::Profile::chosen(s).name(),
|
||||
"native_cache_gib": s.native_cache_gib.unwrap_or(native_mods::DEFAULT_CACHE_GIB),
|
||||
"native_prebuilt": s.native_prebuilt.unwrap_or(false),
|
||||
"no_ai": backend::no_ai(),
|
||||
})
|
||||
}
|
||||
@@ -1314,6 +1338,7 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
|
||||
Cmd::NativeBuild {
|
||||
folder,
|
||||
server_output,
|
||||
prebuilt_out,
|
||||
instance,
|
||||
version,
|
||||
update,
|
||||
@@ -1321,6 +1346,7 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
|
||||
allow_version_mismatch,
|
||||
ignore_pins,
|
||||
profile,
|
||||
prebuilt,
|
||||
} => {
|
||||
if !trust_native_code {
|
||||
return Err("Native mods run with full access to this computer. Read their source, then pass --trust-native-code.".into());
|
||||
@@ -1334,6 +1360,28 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
|
||||
let secs = started.elapsed().as_secs();
|
||||
eprintln!("[{:>2}:{:02}] {}", secs / 60, secs % 60, step.label())
|
||||
};
|
||||
if let Some(out) = prebuilt_out {
|
||||
// For the registry's CI: no instance, no install, no launcher config.
|
||||
let folder = folder.unwrap();
|
||||
let base = match &version {
|
||||
Some(v) => backend::resolve_version(Some(v))?,
|
||||
None => native_mods::base_for(None, &backend::installed(), false)
|
||||
.ok_or_else(backend::no_version_installed)?,
|
||||
};
|
||||
let made = native_prebuilt::export(&base, &folder, &out, profile, steps)?;
|
||||
let left: Vec<&str> = made.left_out.iter().map(|l| l.id.as_str()).collect();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"key": made.request.key(),
|
||||
"target": made.request.target,
|
||||
"base": made.request.base,
|
||||
"version": made.version,
|
||||
"left_out": left,
|
||||
})
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(output) = server_output {
|
||||
// Export mode: a standalone server engine in `output`. No instance is read
|
||||
// or changed, and no launcher config is written; `folder` is required (clap).
|
||||
@@ -1392,6 +1440,10 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
|
||||
Some(folder) => folder,
|
||||
None => native_mods::installed_mods(&target.home)?,
|
||||
};
|
||||
let prebuilt = match prebuilt.as_deref().and_then(native_prebuilt::Mode::parse) {
|
||||
Some(mode) => mode,
|
||||
None => native_prebuilt::Mode::chosen(&backend::Settings::load()),
|
||||
};
|
||||
let built = native_mods::build(
|
||||
&base,
|
||||
&folder,
|
||||
@@ -1399,6 +1451,7 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
|
||||
allow_version_mismatch,
|
||||
ignore_pins,
|
||||
profile,
|
||||
prebuilt,
|
||||
steps,
|
||||
)
|
||||
.map_err(|e| match e.starts_with(native_mods::MISMATCH) {
|
||||
@@ -3970,6 +4023,45 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prebuilt_mode_needs_mods_and_builds_for_no_instance() {
|
||||
let parse = |args: &[&str]| {
|
||||
Cli::try_parse_from(
|
||||
[
|
||||
&["horizon-launcher", "native-build", "--trust-native-code"],
|
||||
args,
|
||||
]
|
||||
.concat(),
|
||||
)
|
||||
};
|
||||
match parse(&["hnmods", "--prebuilt-out", "out", "--version", "nightly"])
|
||||
.unwrap()
|
||||
.command
|
||||
{
|
||||
Some(Cmd::NativeBuild {
|
||||
folder,
|
||||
prebuilt_out,
|
||||
version,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(folder.as_deref(), Some(Path::new("hnmods")));
|
||||
assert_eq!(prebuilt_out.as_deref(), Some(Path::new("out")));
|
||||
assert_eq!(version.as_deref(), Some("nightly"));
|
||||
}
|
||||
_ => panic!("expected native-build"),
|
||||
}
|
||||
// The CI names its mods; an instance's, a server engine or a move to another Horizon
|
||||
// are a player's builds.
|
||||
assert!(parse(&["--prebuilt-out", "out"]).is_err());
|
||||
for other in [
|
||||
&["--instance", "A"][..],
|
||||
&["--server-output", "engine"],
|
||||
&["--update"],
|
||||
] {
|
||||
assert!(parse(&[&["hnmods", "--prebuilt-out", "out"][..], other].concat()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_mode_base_is_an_installed_release_not_a_native_build() {
|
||||
// The export mode picks the newest installed release or Nightly, never a native build
|
||||
|
||||
@@ -33,6 +33,7 @@ pub mod mojang;
|
||||
mod native_cache;
|
||||
pub mod native_fit;
|
||||
pub mod native_mods;
|
||||
pub mod native_prebuilt;
|
||||
pub mod native_status;
|
||||
pub mod nightly;
|
||||
pub mod other_launchers;
|
||||
|
||||
@@ -1049,6 +1049,7 @@ impl Launcher {
|
||||
self.native_started = Some((Instant::now(), 0));
|
||||
self.native_done = None;
|
||||
let profile = native_mods::Profile::chosen(&self.settings);
|
||||
let prebuilt = horizon_launcher::native_prebuilt::Mode::chosen(&self.settings);
|
||||
let tx = self.tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result = native_mods::build(
|
||||
@@ -1058,6 +1059,7 @@ impl Launcher {
|
||||
anyway,
|
||||
on_base,
|
||||
profile,
|
||||
prebuilt,
|
||||
|step| {
|
||||
let _ = tx.send(Msg::NativeProgress(step.label()));
|
||||
},
|
||||
|
||||
+174
-17
@@ -36,7 +36,7 @@ mod isolate;
|
||||
|
||||
pub use isolate::LeftOut;
|
||||
|
||||
const PREFIX: &str = "native-";
|
||||
pub(crate) const PREFIX: &str = "native-";
|
||||
/// horizon-mixins, for bundles that don't name the revision they were tested with.
|
||||
const MIXINS_GIT: &str = "https://github.com/horizon-rust/horizon-mixins";
|
||||
pub const MIXINS_REV: &str = "5a64af938ec86b00d53d16a408aabc0038fb3e57";
|
||||
@@ -198,6 +198,17 @@ fn platform_for_game(base: &str) -> Result<BuildPlatform, String> {
|
||||
windows_build_platform(machine, host.target)
|
||||
}
|
||||
|
||||
/// The Rust target a build on installed version `base` compiles for: a prebuilt build's
|
||||
/// (`native_prebuilt`) is named by it.
|
||||
pub(crate) fn build_target(base: &str) -> Result<&'static str, String> {
|
||||
match platform_for_game(base)?.target {
|
||||
Some(target) => Ok(target),
|
||||
None => rust_toolchain::host()
|
||||
.map(|h| h.target)
|
||||
.ok_or_else(|| architecture_mismatch(std::env::consts::ARCH, "a supported computer")),
|
||||
}
|
||||
}
|
||||
|
||||
fn windows_build_platform(machine: u16, host_target: &str) -> Result<BuildPlatform, String> {
|
||||
let target = rust_toolchain::windows_target_for_machine(machine).ok_or_else(|| {
|
||||
architecture_mismatch(&format!("PE machine 0x{machine:04x}"), "x64 or ARM64")
|
||||
@@ -612,8 +623,31 @@ pub const INSTALLED: &str = "horizon-native-mods";
|
||||
/// The native mods turned on in the game folder `home`, unpacked into a folder [`build`] takes.
|
||||
/// Each `.hnmod` is checked again as the registry checks it (Rust and TOML under
|
||||
/// `mixins/<id>/`, plus files under its `data/`, nothing run).
|
||||
/// Where [`installed_mods`] unpacks an instance's mods.
|
||||
fn installed_folder() -> PathBuf {
|
||||
root().join("installed.incoming")
|
||||
}
|
||||
|
||||
/// The registry's prebuilt build of `instance`'s turned-on mods on official version `base`,
|
||||
/// installed (`native_prebuilt`): None when one of them isn't a registry file or the registry
|
||||
/// has none of this set.
|
||||
fn prebuilt_for(base: &str, instance: &Instance) -> Result<Option<String>, String> {
|
||||
let index = crate::browser::horizon::index()?;
|
||||
let target = build_target(base)?;
|
||||
let Some(request) = crate::native_prebuilt::instance_request(
|
||||
&instance.home,
|
||||
target,
|
||||
&revision_of(base)?,
|
||||
&index,
|
||||
)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
crate::native_prebuilt::install(base, &request, &|_| {})
|
||||
}
|
||||
|
||||
pub fn installed_mods(home: &Path) -> Result<PathBuf, String> {
|
||||
let out = root().join("installed.incoming");
|
||||
let out = installed_folder();
|
||||
let _ = fs::remove_dir_all(&out);
|
||||
let mut paths: Vec<PathBuf> = fs::read_dir(home.join(INSTALLED))
|
||||
.map(|dir| dir.flatten().map(|e| e.path()).collect())
|
||||
@@ -627,12 +661,7 @@ pub fn installed_mods(home: &Path) -> Result<PathBuf, String> {
|
||||
if !crate::browser::java::is_enabled(home, &bundle.manifest.id) {
|
||||
continue;
|
||||
}
|
||||
let dir = out.join(&bundle.manifest.id);
|
||||
for (name, bytes) in &bundle.files {
|
||||
let to = dir.join(name);
|
||||
fs::create_dir_all(to.parent().unwrap_or(&dir)).map_err(|e| e.to_string())?;
|
||||
fs::write(&to, bytes).map_err(|e| format!("{}: {e}", to.display()))?;
|
||||
}
|
||||
unpack(&bundle, &out)?;
|
||||
count += 1;
|
||||
}
|
||||
if count == 0 {
|
||||
@@ -644,6 +673,20 @@ pub fn installed_mods(home: &Path) -> Result<PathBuf, String> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Writes a checked `.hnmod`'s files into `out/<id>/`, as a build reads an installed mod.
|
||||
pub(crate) fn unpack(
|
||||
bundle: &horizon_mods::index::native::Bundle,
|
||||
out: &Path,
|
||||
) -> Result<(), String> {
|
||||
let dir = out.join(&bundle.manifest.id);
|
||||
for (name, bytes) in &bundle.files {
|
||||
let to = dir.join(name);
|
||||
fs::create_dir_all(to.parent().unwrap_or(&dir)).map_err(|e| e.to_string())?;
|
||||
fs::write(&to, bytes).map_err(|e| format!("{}: {e}", to.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A mixin bundle: a folder with a `horizon-mixin.toml`, and the revisions it pins.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Bundle {
|
||||
@@ -1133,6 +1176,11 @@ fn ask_github(repo: &str, revision: &str) -> Result<String, String> {
|
||||
/// use and the player hasn't agreed to try (`anyway`); the rest lists them.
|
||||
pub const MISMATCH: &str = "Made for other Horizon versions: ";
|
||||
|
||||
/// Why a set with a post-build step is never exported as a prebuilt build: the step runs in the
|
||||
/// installed version, on the player's own game files (`native_prebuilt`, #11859).
|
||||
pub const PREBUILT_POST_BUILD: &str =
|
||||
"A mod of this set has a post-build step, which runs on the player's own game files: it is built on the player's computer, never prebuilt.";
|
||||
|
||||
/// Each pinned mod whose Horizon revision isn't `revision`, as `id → pin`, and the build's.
|
||||
fn mismatch(mods: &[Bundle], revision: &str) -> Option<String> {
|
||||
let list: Vec<String> = mods
|
||||
@@ -1608,6 +1656,10 @@ pub enum Step {
|
||||
/// A mod's post-build step running in the new build's folder.
|
||||
PostBuild(String),
|
||||
Install,
|
||||
/// The registry's prebuilt build of these mods being downloaded (`native_prebuilt`).
|
||||
Prebuilt(f32),
|
||||
/// The registry has no prebuilt build of these mods: they are compiled here.
|
||||
NoPrebuilt,
|
||||
/// This mod was left out of the build.
|
||||
LeftOut(LeftOut),
|
||||
/// The build ended: seconds it took.
|
||||
@@ -1664,6 +1716,13 @@ impl Step {
|
||||
Self::PostBuildCompile(id) => trf("Building {name}'s post-build step", &[("name", id)]),
|
||||
Self::PostBuild(id) => trf("Running {name}'s post-build step", &[("name", id)]),
|
||||
Self::Install => tr("Installing the build").into(),
|
||||
Self::Prebuilt(done) => trf(
|
||||
"Downloading a ready-made build of these mods: {done}%",
|
||||
&[("done", &format!("{:.0}", done * 100.0))],
|
||||
),
|
||||
Self::NoPrebuilt => {
|
||||
tr("No ready-made build of these mods yet: building them on this computer.").into()
|
||||
}
|
||||
Self::LeftOut(left) => left.label(),
|
||||
Self::Done {
|
||||
secs,
|
||||
@@ -2219,9 +2278,14 @@ fn pipeline(
|
||||
progress: &impl Fn(Step),
|
||||
skip: &[LeftOut],
|
||||
isolate: bool,
|
||||
export: Option<&Path>,
|
||||
) -> Result<Built, Failed> {
|
||||
let started = std::time::Instant::now();
|
||||
backend::need_prepared(base)?;
|
||||
// An export (`native_prebuilt::export`, the registry's CI) never plays: it needs only the
|
||||
// version's program, never the player's prepared game files.
|
||||
if export.is_none() {
|
||||
backend::need_prepared(base)?;
|
||||
}
|
||||
let platform = platform_for_game(base)?;
|
||||
fs::create_dir_all(root()).map_err(|e| e.to_string())?;
|
||||
// The mods are copied first: the build and its key use the copy, whatever happens to the
|
||||
@@ -2375,7 +2439,7 @@ fn pipeline(
|
||||
.map(|m| Ok((m.id.clone(), mod_hash(m)?)))
|
||||
.collect::<Result<_, String>>()?,
|
||||
};
|
||||
let exists = target.directory(&version).join(".installed").is_file();
|
||||
let exists = export.is_none() && target.directory(&version).join(".installed").is_file();
|
||||
let plan = classify(
|
||||
exists,
|
||||
last_record(target, profile, platform.cache_key()).as_ref(),
|
||||
@@ -2465,6 +2529,11 @@ fn pipeline(
|
||||
let stage = stage_dir(&real)?;
|
||||
sync(&fresh, &stage)?;
|
||||
let _ = fs::remove_dir_all(&fresh);
|
||||
// A post-build step runs in the installed version, on the player's game files: a build
|
||||
// with one is only ever made on that computer.
|
||||
if export.is_some() && !crates::post_builds(&stage)?.is_empty() {
|
||||
return Err(PREBUILT_POST_BUILD.to_string().into());
|
||||
}
|
||||
if crates::any(&stage) || has_api {
|
||||
progress(Step::Crates);
|
||||
lock_crates(&rust, &source, &stage, &mods)?;
|
||||
@@ -2588,14 +2657,21 @@ fn pipeline(
|
||||
})).collect::<Vec<_>>(),
|
||||
"left_out": left_out.iter().map(LeftOut::to_json).collect::<Vec<_>>(),
|
||||
});
|
||||
install(base, &version, target, &binary, &about, |dir| {
|
||||
for (step, program) in &post {
|
||||
progress(Step::PostBuild(step.mod_id.clone()));
|
||||
let cmd = crates::post_build(program, step, dir, &real.join("cache"), &stage)?;
|
||||
run(cmd, &format!("{}'s post-build step", step.mod_id), |_| {})?;
|
||||
match export {
|
||||
Some(out) => {
|
||||
copy_out(&binary, &out.join(target.binary()))?;
|
||||
fs::write(out.join("native-mods.json"), about.to_string())
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
None => install(base, &version, target, &binary, &about, |dir| {
|
||||
for (step, program) in &post {
|
||||
progress(Step::PostBuild(step.mod_id.clone()));
|
||||
let cmd = crates::post_build(program, step, dir, &real.join("cache"), &stage)?;
|
||||
run(cmd, &format!("{}'s post-build step", step.mod_id), |_| {})?;
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
}
|
||||
let _ = fs::remove_dir_all(real.join("programs"));
|
||||
} else {
|
||||
// Used again: it counts as the newest of the builds kept (`prune`).
|
||||
@@ -2669,6 +2745,7 @@ fn isolated(
|
||||
/// with the newest horizon-mixins they name, only when the player agreed to try (`anyway`): else
|
||||
/// the error starts with [`MISMATCH`] and says which. With `on_base`, every mod builds on `base`'s
|
||||
/// own commit, pinned or not: the player's Rebuild on Horizon.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build(
|
||||
base: &str,
|
||||
folder: &Path,
|
||||
@@ -2676,11 +2753,38 @@ pub fn build(
|
||||
anyway: bool,
|
||||
on_base: bool,
|
||||
profile: Profile,
|
||||
prebuilt: crate::native_prebuilt::Mode,
|
||||
progress: impl Fn(Step),
|
||||
) -> Result<String, String> {
|
||||
if let Some(error) = backend::native_mod_architecture_error() {
|
||||
return Err(error);
|
||||
}
|
||||
use crate::native_prebuilt::Mode;
|
||||
// The instance's own mods, as the registry builds them: a folder, a developer profile or a
|
||||
// build forced off its pins is compiled here.
|
||||
let registry_set =
|
||||
folder == installed_folder() && profile != Profile::Dev && !anyway && !on_base;
|
||||
if registry_set && prebuilt != Mode::Never {
|
||||
let origin = about(base).map_or(base.to_owned(), |a| a.base);
|
||||
match prebuilt_for(&origin, instance) {
|
||||
Ok(Some(version)) => {
|
||||
instances::edit(&instance.id, |meta| meta.version = Some(version.clone()))?;
|
||||
prune(&version);
|
||||
return Ok(version);
|
||||
}
|
||||
Ok(None) if prebuilt == Mode::Only => {
|
||||
return Err("The registry has no prebuilt build of these mods.".into())
|
||||
}
|
||||
Err(e) if prebuilt == Mode::Only => return Err(e),
|
||||
Ok(None) => progress(Step::NoPrebuilt),
|
||||
Err(e) => {
|
||||
log_warning(&format!("The prebuilt build was not used: {e}"));
|
||||
progress(Step::NoPrebuilt);
|
||||
}
|
||||
}
|
||||
} else if prebuilt == Mode::Only {
|
||||
return Err("Only the instance's registry mods, with the performance or release profile, have prebuilt builds.".into());
|
||||
}
|
||||
logged(|| {
|
||||
let built = isolated(
|
||||
|skip| {
|
||||
@@ -2694,6 +2798,7 @@ pub fn build(
|
||||
&progress,
|
||||
skip,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
},
|
||||
&progress,
|
||||
@@ -2713,6 +2818,40 @@ pub fn build(
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds the bundles in `folder` as a player's launcher builds them on installed version
|
||||
/// `base` (left-out mods included), and writes the program and its `native-mods.json` into `out`
|
||||
/// instead of installing it: a prebuilt build for the registry's CI (`native_prebuilt::export`).
|
||||
/// Nothing is installed, no instance is changed, and `base` needs no prepared game files.
|
||||
/// Returns the version a player's build of the same set would install, and the mods left out.
|
||||
pub(crate) fn build_prebuilt(
|
||||
base: &str,
|
||||
folder: &Path,
|
||||
out: &Path,
|
||||
profile: Profile,
|
||||
progress: impl Fn(Step),
|
||||
) -> Result<(String, Vec<LeftOut>), String> {
|
||||
logged(|| {
|
||||
let built = isolated(
|
||||
|skip| {
|
||||
pipeline(
|
||||
base,
|
||||
folder,
|
||||
false,
|
||||
false,
|
||||
profile,
|
||||
Target::Client,
|
||||
&progress,
|
||||
skip,
|
||||
true,
|
||||
Some(out),
|
||||
)
|
||||
},
|
||||
&progress,
|
||||
)?;
|
||||
Ok((built.version, built.left_out))
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds `folder`'s mods as the headless engine a dedicated server runs (`horizon-engine`,
|
||||
/// docs/server.md): the same pipeline as [`build`] on the same revision, compiled with
|
||||
/// `--no-default-features`, installed as its own version (its key distinct from the game's, so
|
||||
@@ -2768,6 +2907,7 @@ pub fn build_server(
|
||||
&progress,
|
||||
&[],
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.map_err(String::from)?;
|
||||
export(&Target::Server.directory(&built.version), &output)?;
|
||||
@@ -2901,6 +3041,23 @@ fn install(
|
||||
fs::write(dir.join(".installed"), "").map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Installs a checked prebuilt `binary` as version `version` on installed version `base`, with
|
||||
/// record `about` (`native_prebuilt::install`).
|
||||
pub(crate) fn install_prebuilt(
|
||||
base: &str,
|
||||
version: &str,
|
||||
binary: &Path,
|
||||
about: &serde_json::Value,
|
||||
) -> Result<(), String> {
|
||||
install(base, version, Target::Client, binary, about, |_| Ok(()))
|
||||
}
|
||||
|
||||
/// Whether game build `version` is installed; if so, it counts as the newest kept ([`prune`]).
|
||||
pub(crate) fn reuse_installed(version: &str) -> bool {
|
||||
let installed = Target::Client.directory(version).join(".installed");
|
||||
installed.is_file() && fs::write(&installed, "").is_ok()
|
||||
}
|
||||
|
||||
/// 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 (`build_import`).
|
||||
pub(crate) fn copy_base(from: &Path, to: &Path, target: Target) -> Result<(), String> {
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
//! Prebuilt native builds (#11859): the game compiled for one exact set of registry native mods by
|
||||
//! horizon-mods' CI, so players download it instead of compiling.
|
||||
//!
|
||||
//! A build is named by its request (`horizon_release_sign::native_build::Request`): the target, the
|
||||
//! commit of the release or Nightly it starts from, and each mod's id, version and the SHA-256 of
|
||||
//! its `.hnmod`. [`request_for`] computes it the same way on the player's computer and in CI.
|
||||
//! [`export`] is CI's side: `horizon-launcher native-build --prebuilt-out DIR FOLDER` builds the
|
||||
//! `.hnmod` files in FOLDER with the player's pipeline (the same pins, hook points and left-out
|
||||
//! mods), and writes the program, its `native-mods.json` and the request into DIR, for the workflow
|
||||
//! to sign. A set with a post-build step is never exported: the step runs on the player's game files.
|
||||
|
||||
use crate::native_mods::{self, LeftOut, Profile, Step};
|
||||
use horizon_release_sign::native_build::{self, Mod, Request};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The request's text in an export's folder: what the workflow checks against its own.
|
||||
pub const REQUEST_FILE: &str = "request.txt";
|
||||
|
||||
/// The `.hnmod` files directly in `dir`, by name.
|
||||
pub fn hnmod_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
|
||||
let mut files: Vec<PathBuf> = fs::read_dir(dir)
|
||||
.map_err(|e| format!("{}: {e}", dir.display()))?
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_file() && p.extension().is_some_and(|e| e == "hnmod"))
|
||||
.collect();
|
||||
files.sort();
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// The request for building `files` (registry `.hnmod`s, each checked as the registry checks it)
|
||||
/// for `target` on commit `base`: each mod's id and version from its `horizon-native.json`, and the
|
||||
/// file's SHA-256.
|
||||
pub fn request_for(target: &str, base: &str, files: &[PathBuf]) -> Result<Request, String> {
|
||||
let mods = files
|
||||
.iter()
|
||||
.map(|path| {
|
||||
let bytes = fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let bundle = horizon_mods::index::native::Bundle::read(path)
|
||||
.map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Ok(Mod {
|
||||
id: bundle.manifest.id,
|
||||
version: bundle.manifest.version,
|
||||
sha256: horizon_release_sign::sha256_hex(&bytes),
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?;
|
||||
Request::new(target, base, mods)
|
||||
}
|
||||
|
||||
/// What [`export`] made.
|
||||
#[derive(Debug)]
|
||||
pub struct Exported {
|
||||
pub request: Request,
|
||||
/// The version a player's own build of this set would install (`native-<commit>-<key>`).
|
||||
pub version: String,
|
||||
/// The mods the build left out, as a player's build leaves them out.
|
||||
pub left_out: Vec<LeftOut>,
|
||||
}
|
||||
|
||||
/// Builds every `.hnmod` in `folder` on installed version `base` as a player's launcher would,
|
||||
/// and writes into `out` (a new folder): the program, its `native-mods.json` and [`REQUEST_FILE`].
|
||||
/// Nothing is installed and `base` needs no prepared game files.
|
||||
pub fn export(
|
||||
base: &str,
|
||||
folder: &Path,
|
||||
out: &Path,
|
||||
profile: Profile,
|
||||
progress: impl Fn(Step),
|
||||
) -> Result<Exported, String> {
|
||||
let files = hnmod_files(folder)?;
|
||||
if files.is_empty() {
|
||||
return Err(format!("{} holds no .hnmod file.", folder.display()));
|
||||
}
|
||||
let empty = fs::read_dir(out).map_or(true, |mut dir| dir.next().is_none());
|
||||
if !empty {
|
||||
return Err(format!(
|
||||
"{} already holds files: export into a new folder.",
|
||||
out.display()
|
||||
));
|
||||
}
|
||||
let request = request_for(
|
||||
native_mods::build_target(base)?,
|
||||
&native_mods::revision_of(base)?,
|
||||
&files,
|
||||
)?;
|
||||
fs::create_dir_all(out).map_err(|e| format!("{}: {e}", out.display()))?;
|
||||
// Unpacked as a player's installed mods are (`native_mods::installed_mods`), so the build's
|
||||
// record names them alike.
|
||||
let incoming = native_mods::root().join("prebuilt.incoming");
|
||||
let _ = fs::remove_dir_all(&incoming);
|
||||
for path in &files {
|
||||
let bundle = horizon_mods::index::native::Bundle::read(path)
|
||||
.map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
native_mods::unpack(&bundle, &incoming)?;
|
||||
}
|
||||
let built = native_mods::build_prebuilt(base, &incoming, out, profile, progress);
|
||||
let _ = fs::remove_dir_all(&incoming);
|
||||
let (version, left_out) = built?;
|
||||
fs::write(out.join(REQUEST_FILE), request.text()).map_err(|e| e.to_string())?;
|
||||
Ok(Exported {
|
||||
request,
|
||||
version,
|
||||
left_out,
|
||||
})
|
||||
}
|
||||
|
||||
/// The repository whose releases hold the prebuilt builds.
|
||||
pub const REGISTRY: &str = "horizon-rust/horizon-mods";
|
||||
/// The largest archive downloaded (horizon-mods' native-build.yml signs none larger): checked
|
||||
/// before the download and capped during it, so a bad asset costs at most this much disk and no
|
||||
/// memory before its signature is checked.
|
||||
const MAX_ARCHIVE: u64 = 512 << 20;
|
||||
/// The largest file unpacked from a checked archive.
|
||||
const MAX_UNPACKED: u64 = 1 << 30;
|
||||
|
||||
/// Whether a build of an instance's mods first looks for a prebuilt one.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
/// Download it when the registry has one, else compile.
|
||||
Auto,
|
||||
/// Always compile on this computer.
|
||||
#[default]
|
||||
Never,
|
||||
/// Download it, or fail: never compile.
|
||||
Only,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
pub fn parse(text: &str) -> Option<Self> {
|
||||
match text {
|
||||
"auto" => Some(Self::Auto),
|
||||
"never" => Some(Self::Never),
|
||||
"only" => Some(Self::Only),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The player's setting (`settings native-prebuilt on|off`): off unless turned on, until the
|
||||
/// registry's signing pipeline has been checked end to end (#11859); turning it on by default
|
||||
/// is its own change.
|
||||
pub fn chosen(settings: &crate::backend::Settings) -> Self {
|
||||
match settings.native_prebuilt {
|
||||
Some(true) => Self::Auto,
|
||||
_ => Self::Never,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The request for the native mods turned on in instance home `home`, built on `commit` for
|
||||
/// `target`, when every one of them is a registry file `index` lists (by its SHA-256) and none
|
||||
/// was taken down; None when one isn't (a mod from GitHub or a folder is built here).
|
||||
pub fn instance_request(
|
||||
home: &Path,
|
||||
target: &str,
|
||||
commit: &str,
|
||||
index: &horizon_mods::index::Index,
|
||||
) -> Result<Option<Request>, String> {
|
||||
let mut files = Vec::new();
|
||||
for path in hnmod_files(&home.join(native_mods::INSTALLED))? {
|
||||
let bundle = horizon_mods::index::native::Bundle::read(&path)
|
||||
.map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if crate::browser::java::is_enabled(home, &bundle.manifest.id) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
if files.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let request = request_for(target, commit, &files)?;
|
||||
let listed = request.mods.iter().all(|m| {
|
||||
index.revoked(&m.id, &m.sha256).is_none()
|
||||
&& index.project(&m.id).is_some_and(|p| {
|
||||
p.versions.iter().any(|v| {
|
||||
v.version == m.version && v.file.sha256.eq_ignore_ascii_case(&m.sha256)
|
||||
})
|
||||
})
|
||||
});
|
||||
Ok(listed.then_some(request))
|
||||
}
|
||||
|
||||
/// Where a prebuilt asset of `request` is: its release of [`REGISTRY`].
|
||||
pub fn asset_url(request: &Request, asset: &str) -> String {
|
||||
format!(
|
||||
"https://github.com/{REGISTRY}/releases/download/{}/{asset}",
|
||||
native_build::release_tag(&request.base)
|
||||
)
|
||||
}
|
||||
|
||||
/// A checked prebuilt build, unpacked: its program and record.
|
||||
#[derive(Debug)]
|
||||
pub struct Unpacked {
|
||||
pub binary: PathBuf,
|
||||
pub about: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Checks a downloaded build of `request` and unpacks it into `into`: `keys` signed `archive`
|
||||
/// (`signatures` is its signature file) as this request's build; it holds only the program, its
|
||||
/// `native-mods.json` and `request.txt`; that request is this one; and its record builds no mod
|
||||
/// the request doesn't name. Anything else is refused before the program is written anywhere.
|
||||
pub fn check(
|
||||
request: &Request,
|
||||
archive: &Path,
|
||||
signatures: &str,
|
||||
keys: &[horizon_release_sign::PublicKey],
|
||||
into: &Path,
|
||||
) -> Result<Unpacked, String> {
|
||||
use std::io::Read;
|
||||
let key = request.key();
|
||||
let name = native_build::archive(&key, &request.target);
|
||||
let read_error = |e: std::io::Error| format!("{}: {e}", archive.display());
|
||||
// Hashed from disk first: nothing unsigned is read into memory.
|
||||
let size = fs::metadata(archive).map_err(read_error)?.len();
|
||||
if size > MAX_ARCHIVE {
|
||||
return Err(format!("{name}: larger than {MAX_ARCHIVE} bytes"));
|
||||
}
|
||||
let sha = sha256_file(archive).map_err(read_error)?;
|
||||
native_build::verify(&key, &request.target, &sha, &name, signatures, keys)?;
|
||||
// The bytes unpacked are the bytes the signature covers: hashed again once in memory.
|
||||
let bytes = fs::read(archive).map_err(read_error)?;
|
||||
if horizon_release_sign::sha256_hex(&bytes) != sha {
|
||||
return Err(format!("{name} changed while it was checked."));
|
||||
}
|
||||
let client = crate::backend::CLIENT;
|
||||
let mut files: std::collections::BTreeMap<String, Vec<u8>> = Default::default();
|
||||
let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(bytes.as_slice()));
|
||||
for entry in tar.entries().map_err(|e| e.to_string())? {
|
||||
let mut entry = entry.map_err(|e| e.to_string())?;
|
||||
let path = entry.path().map_err(|e| e.to_string())?.into_owned();
|
||||
let path = path.to_string_lossy().trim_start_matches("./").to_string();
|
||||
if entry.header().entry_type().is_dir() && path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let known = [client, "native-mods.json", REQUEST_FILE].contains(&path.as_str());
|
||||
if !known || !entry.header().entry_type().is_file() || files.contains_key(&path) {
|
||||
return Err(format!("{name}: unexpected entry {path:?}"));
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
(&mut entry)
|
||||
.take(MAX_UNPACKED)
|
||||
.read_to_end(&mut data)
|
||||
.map_err(|e| e.to_string())?;
|
||||
files.insert(path, data);
|
||||
}
|
||||
let text = |file: &str| {
|
||||
files
|
||||
.get(file)
|
||||
.and_then(|b| std::str::from_utf8(b).ok())
|
||||
.ok_or_else(|| format!("{name}: no {file}"))
|
||||
};
|
||||
if text(REQUEST_FILE)? != request.text() {
|
||||
return Err(format!("{name} was built for another set of mods."));
|
||||
}
|
||||
let about: serde_json::Value =
|
||||
serde_json::from_str(text("native-mods.json")?).map_err(|e| format!("{name}: {e}"))?;
|
||||
let asked = |id: &str, version: &str| {
|
||||
request
|
||||
.mods
|
||||
.iter()
|
||||
.any(|m| m.id == id && m.version == version)
|
||||
};
|
||||
let built = about["mods"].as_array().ok_or(format!("{name}: no mods"))?;
|
||||
let named = built.iter().all(|m| {
|
||||
asked(
|
||||
m["id"].as_str().unwrap_or_default(),
|
||||
m["version"].as_str().unwrap_or_default(),
|
||||
)
|
||||
});
|
||||
if built.is_empty() || !named || about["horizon"].as_str().is_none_or(|h| h.len() != 40) {
|
||||
return Err(format!("{name}: its record doesn't match its request"));
|
||||
}
|
||||
let program = files.remove(client).ok_or(format!("{name}: no {client}"))?;
|
||||
fs::create_dir_all(into).map_err(|e| e.to_string())?;
|
||||
let binary = into.join(client);
|
||||
fs::write(&binary, program).map_err(|e| e.to_string())?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&binary, fs::Permissions::from_mode(0o755))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(Unpacked { binary, about })
|
||||
}
|
||||
|
||||
/// The SHA-256 of the file at `path`, read in pieces.
|
||||
fn sha256_file(path: &Path) -> std::io::Result<String> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
std::io::copy(&mut fs::File::open(path)?, &mut hasher)?;
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
/// Streams `url` to the file `to`, at most [`MAX_ARCHIVE`] bytes (one announced as larger is
|
||||
/// refused before its body); None when there is no such file.
|
||||
fn fetch(url: &str, to: &Path, progress: &impl Fn(Step)) -> Result<Option<()>, String> {
|
||||
use std::io::{Read, Write};
|
||||
let response = match crate::backend::agent(url).get(url).call() {
|
||||
Ok(r) => r,
|
||||
Err(ureq::Error::Status(404, _)) => return Ok(None),
|
||||
Err(e) => return Err(format!("{url}: {e}")),
|
||||
};
|
||||
let total: Option<u64> = response
|
||||
.header("Content-Length")
|
||||
.and_then(|l| l.parse().ok());
|
||||
if total.is_some_and(|t| t > MAX_ARCHIVE) {
|
||||
return Err(format!("{url}: larger than {MAX_ARCHIVE} bytes"));
|
||||
}
|
||||
let mut reader = response.into_reader().take(MAX_ARCHIVE);
|
||||
let mut file = fs::File::create(to).map_err(|e| format!("{}: {e}", to.display()))?;
|
||||
let (mut done, mut buffer) = (0u64, vec![0; 1 << 16]);
|
||||
loop {
|
||||
let n = reader
|
||||
.read(&mut buffer)
|
||||
.map_err(|e| format!("{url}: {e}"))?;
|
||||
if n == 0 {
|
||||
return Ok(Some(()));
|
||||
}
|
||||
file.write_all(&buffer[..n]).map_err(|e| e.to_string())?;
|
||||
done += n as u64;
|
||||
if let Some(total) = total.filter(|t| *t > 0) {
|
||||
progress(Step::Prebuilt(done as f32 / total as f32));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs the registry's prebuilt build of `request` on installed version `base`, as the
|
||||
/// version a build of these mods would be, after [`check`]ing it. None when the registry has
|
||||
/// no build of this set; an error when one exists but fails a check.
|
||||
pub fn install(
|
||||
base: &str,
|
||||
request: &Request,
|
||||
progress: &impl Fn(Step),
|
||||
) -> Result<Option<String>, String> {
|
||||
let key = request.key();
|
||||
// Named by the request alone, so a set switched off and on again plays the kept build.
|
||||
let version = format!(
|
||||
"{}{}-{}",
|
||||
native_mods::PREFIX,
|
||||
&request.base[..7],
|
||||
&key[..12]
|
||||
);
|
||||
if native_mods::reuse_installed(&version) {
|
||||
return Ok(Some(version));
|
||||
}
|
||||
let signature = native_build::signature(&key, &request.target);
|
||||
let signature_url = asset_url(request, &signature);
|
||||
let signatures = match crate::backend::agent(&signature_url)
|
||||
.get(&signature_url)
|
||||
.call()
|
||||
{
|
||||
Ok(r) => r.into_string().map_err(|e| e.to_string())?,
|
||||
Err(ureq::Error::Status(404, _)) => return Ok(None),
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
let work = native_mods::root().join("prebuilt");
|
||||
let _ = fs::remove_dir_all(&work);
|
||||
fs::create_dir_all(&work).map_err(|e| e.to_string())?;
|
||||
let archive = work.join(native_build::archive(&key, &request.target));
|
||||
progress(Step::Prebuilt(0.0));
|
||||
let url = asset_url(request, &native_build::archive(&key, &request.target));
|
||||
let result = fetch(&url, &archive, progress).and_then(|fetched| {
|
||||
if fetched.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut unpacked = check(
|
||||
request,
|
||||
&archive,
|
||||
&signatures,
|
||||
&native_build::trusted_keys(),
|
||||
&work.join("unpacked"),
|
||||
)?;
|
||||
unpacked.about["base"] = serde_json::json!(base);
|
||||
unpacked.about["prebuilt"] = serde_json::json!({ "key": key, "url": url });
|
||||
progress(Step::Install);
|
||||
native_mods::install_prebuilt(base, &version, &unpacked.binary, &unpacked.about)?;
|
||||
Ok(Some(version))
|
||||
});
|
||||
// A failed or refused download leaves nothing behind either.
|
||||
let _ = fs::remove_dir_all(&work);
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("native-prebuilt-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// A registry `.hnmod` for `id` at `version`, as `.hnmod` files are zipped.
|
||||
fn hnmod(dir: &Path, file: &str, id: &str, version: &str) -> PathBuf {
|
||||
hnmod_with(dir, file, id, version, "")
|
||||
}
|
||||
|
||||
/// [`hnmod`] with `extra` at the end of its manifest.
|
||||
fn hnmod_with(dir: &Path, file: &str, id: &str, version: &str, extra: &str) -> PathBuf {
|
||||
let pin = "a".repeat(40);
|
||||
let path = dir.join(file);
|
||||
let mut zip = zip::ZipWriter::new(fs::File::create(&path).unwrap());
|
||||
let manifest = serde_json::json!({
|
||||
"schema": 1, "id": id, "version": version, "game": "26.3", "horizon": pin, "mixins": pin
|
||||
});
|
||||
let source = format!(
|
||||
"[mod]\nid = \"{id}\"\nversion = \"{version}\"\n[compatibility]\nhorizon_revision = \"{pin}\"\n{extra}"
|
||||
);
|
||||
for (name, text) in [
|
||||
("horizon-native.json".to_string(), manifest.to_string()),
|
||||
(format!("mixins/{id}/horizon-mixin.toml"), source),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(text.as_bytes()).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn sha256(path: &Path) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
Sha256::digest(fs::read(path).unwrap())
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_names_each_file_by_its_manifest_and_its_bytes() {
|
||||
let dir = scratch("request");
|
||||
// The file name is not the id: the manifest says what the mod is.
|
||||
let zoom = hnmod(&dir, "b.hnmod", "zoom", "2.0.0");
|
||||
let minimap = hnmod(&dir, "a-renamed.hnmod", "minimap", "1.4.0");
|
||||
fs::write(dir.join("notes.txt"), "not a mod").unwrap();
|
||||
let files = hnmod_files(&dir).unwrap();
|
||||
assert_eq!(files, vec![minimap.clone(), zoom.clone()]);
|
||||
let base = "6".repeat(40);
|
||||
let request = request_for("x86_64-unknown-linux-gnu", &base, &files).unwrap();
|
||||
let expected = Request::new(
|
||||
"x86_64-unknown-linux-gnu",
|
||||
&base,
|
||||
vec![
|
||||
Mod {
|
||||
id: "zoom".into(),
|
||||
version: "2.0.0".into(),
|
||||
sha256: sha256(&zoom),
|
||||
},
|
||||
Mod {
|
||||
id: "minimap".into(),
|
||||
version: "1.4.0".into(),
|
||||
sha256: sha256(&minimap),
|
||||
},
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(request, expected);
|
||||
// Another file for the same mod and version is another build.
|
||||
let other = scratch("request-other");
|
||||
let edited = hnmod_with(&other, "b.hnmod", "zoom", "2.0.0", "# edited\n");
|
||||
fs::copy(&minimap, other.join("a.hnmod")).unwrap();
|
||||
let changed = request_for(
|
||||
"x86_64-unknown-linux-gnu",
|
||||
&base,
|
||||
&hnmod_files(&other).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(sha256(&edited), sha256(&zoom));
|
||||
assert_ne!(changed.key(), request.key());
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
fs::remove_dir_all(other).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_the_registry_would_refuse_names_no_build() {
|
||||
let dir = scratch("refused");
|
||||
fs::write(dir.join("broken.hnmod"), b"not a zip").unwrap();
|
||||
let files = hnmod_files(&dir).unwrap();
|
||||
assert!(request_for("x86_64-unknown-linux-gnu", &"6".repeat(40), &files).is_err());
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_export_needs_mods_and_a_new_folder_before_anything_runs() {
|
||||
let mods = scratch("export-mods");
|
||||
let out = scratch("export-out");
|
||||
let nothing = export("v0.0.1", &mods, &out.join("new"), Profile::Release, |_| {});
|
||||
assert!(nothing.unwrap_err().contains("no .hnmod"));
|
||||
hnmod(&mods, "zoom.hnmod", "zoom", "2.0.0");
|
||||
fs::write(out.join("left-over"), "").unwrap();
|
||||
let taken = export("v0.0.1", &mods, &out, Profile::Release, |_| {});
|
||||
assert!(taken.unwrap_err().contains("already holds files"));
|
||||
fs::remove_dir_all(mods).unwrap();
|
||||
fs::remove_dir_all(out).unwrap();
|
||||
}
|
||||
|
||||
/// A signed prebuilt archive for `request`, as native-build.yml makes and signs one.
|
||||
fn signed_archive(
|
||||
dir: &Path,
|
||||
request: &Request,
|
||||
files: &[(&str, &[u8])],
|
||||
) -> (PathBuf, String, horizon_release_sign::PublicKey) {
|
||||
let (signer, _) = horizon_release_sign::SigningKey::generate().unwrap();
|
||||
let key = request.key();
|
||||
let name = native_build::archive(&key, &request.target);
|
||||
let path = dir.join(&name);
|
||||
let gz = flate2::write::GzEncoder::new(
|
||||
fs::File::create(&path).unwrap(),
|
||||
flate2::Compression::fast(),
|
||||
);
|
||||
let mut tar = tar::Builder::new(gz);
|
||||
for (file, bytes) in files {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_mode(0o755);
|
||||
header.set_cksum();
|
||||
tar.append_data(&mut header, format!("./{file}"), *bytes)
|
||||
.unwrap();
|
||||
}
|
||||
tar.into_inner().unwrap().finish().unwrap();
|
||||
let sha = horizon_release_sign::sha256_hex(&fs::read(&path).unwrap());
|
||||
let message = native_build::message(&key, &request.target, &sha, &name);
|
||||
(path, signer.sign_bytes(&message) + "\n", signer.public())
|
||||
}
|
||||
|
||||
fn minimap_request() -> Request {
|
||||
Request::new(
|
||||
"x86_64-unknown-linux-gnu",
|
||||
&"6".repeat(40),
|
||||
vec![Mod {
|
||||
id: "minimap".into(),
|
||||
version: "2.0.0".into(),
|
||||
sha256: "a".repeat(64),
|
||||
}],
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn record(mods: &[(&str, &str)]) -> Vec<u8> {
|
||||
let mods: Vec<_> = mods
|
||||
.iter()
|
||||
.map(|(id, v)| serde_json::json!({"id": id, "version": v, "sha256": "f".repeat(64)}))
|
||||
.collect();
|
||||
serde_json::json!({"base": "nightly-x", "horizon": "6".repeat(40), "mods": mods, "left_out": []})
|
||||
.to_string()
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signed_build_of_this_set_unpacks_and_nothing_else_does() {
|
||||
let dir = scratch("check");
|
||||
let request = minimap_request();
|
||||
let client = crate::backend::CLIENT;
|
||||
let text = request.text();
|
||||
let good = record(&[("minimap", "2.0.0")]);
|
||||
let files: [(&str, &[u8]); 3] = [
|
||||
(client, b"the game"),
|
||||
("native-mods.json", &good),
|
||||
(REQUEST_FILE, text.as_bytes()),
|
||||
];
|
||||
let (archive, signature, public) = signed_archive(&dir, &request, &files);
|
||||
let into = dir.join("unpacked");
|
||||
let unpacked = check(&request, &archive, &signature, &[public], &into).unwrap();
|
||||
assert_eq!(fs::read(&unpacked.binary).unwrap(), b"the game");
|
||||
assert_eq!(unpacked.about["mods"][0]["id"], "minimap");
|
||||
|
||||
// Signed by a key the launcher doesn't list.
|
||||
let (stranger, _) = horizon_release_sign::SigningKey::generate().unwrap();
|
||||
assert!(check(&request, &archive, &signature, &[stranger.public()], &into).is_err());
|
||||
// The same archive asked for as another set.
|
||||
let other = Request::new(
|
||||
&request.target,
|
||||
&request.base,
|
||||
vec![Mod {
|
||||
id: "zoom".into(),
|
||||
version: "2.0.0".into(),
|
||||
sha256: "b".repeat(64),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(check(&other, &archive, &signature, &[public], &into).is_err());
|
||||
// One byte changed after signing.
|
||||
let mut bytes = fs::read(&archive).unwrap();
|
||||
let last = bytes.len() - 1;
|
||||
bytes[last] ^= 1;
|
||||
fs::write(&archive, bytes).unwrap();
|
||||
assert!(check(&request, &archive, &signature, &[public], &into).is_err());
|
||||
// Larger than any signed build: refused before it is read, nothing unpacked.
|
||||
let huge = dir.join("huge");
|
||||
fs::File::create(&archive)
|
||||
.unwrap()
|
||||
.set_len(MAX_ARCHIVE + 1)
|
||||
.unwrap();
|
||||
assert!(check(&request, &archive, &signature, &[public], &huge).is_err());
|
||||
assert!(!huge.exists());
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prebuilt_builds_are_off_until_the_player_turns_them_on() {
|
||||
let mut settings = crate::backend::Settings::default();
|
||||
assert_eq!(Mode::chosen(&settings), Mode::Never);
|
||||
settings.native_prebuilt = Some(false);
|
||||
assert_eq!(Mode::chosen(&settings), Mode::Never);
|
||||
settings.native_prebuilt = Some(true);
|
||||
assert_eq!(Mode::chosen(&settings), Mode::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signed_archive_that_lies_about_its_contents_is_refused() {
|
||||
let request = minimap_request();
|
||||
let client = crate::backend::CLIENT;
|
||||
let text = request.text();
|
||||
let good = record(&[("minimap", "2.0.0")]);
|
||||
let extra = record(&[("minimap", "2.0.0"), ("xray", "1.0.0")]);
|
||||
let other_text = text.replace("2.0.0", "2.0.1");
|
||||
let cases: [Vec<(&str, &[u8])>; 4] = [
|
||||
// A file besides the three.
|
||||
vec![
|
||||
(client, b"g"),
|
||||
("native-mods.json", &good),
|
||||
(REQUEST_FILE, text.as_bytes()),
|
||||
("run-me.sh", b"#!/bin/sh"),
|
||||
],
|
||||
// A record naming a mod the request doesn't.
|
||||
vec![
|
||||
(client, b"g"),
|
||||
("native-mods.json", &extra),
|
||||
(REQUEST_FILE, text.as_bytes()),
|
||||
],
|
||||
// Another set's request inside, signed as this one's build.
|
||||
vec![
|
||||
(client, b"g"),
|
||||
("native-mods.json", &good),
|
||||
(REQUEST_FILE, other_text.as_bytes()),
|
||||
],
|
||||
// No program.
|
||||
vec![("native-mods.json", &good), (REQUEST_FILE, text.as_bytes())],
|
||||
];
|
||||
for (i, files) in cases.iter().enumerate() {
|
||||
let dir = scratch(&format!("lies-{i}"));
|
||||
let (archive, signature, public) = signed_archive(&dir, &request, files);
|
||||
let into = dir.join("unpacked");
|
||||
assert!(
|
||||
check(&request, &archive, &signature, &[public], &into).is_err(),
|
||||
"case {i}"
|
||||
);
|
||||
assert!(!into.join(client).exists(), "case {i}: nothing is written");
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_instance_of_registry_files_asks_for_a_prebuilt_build() {
|
||||
let home = scratch("instance");
|
||||
let installed = home.join(native_mods::INSTALLED);
|
||||
fs::create_dir_all(&installed).unwrap();
|
||||
let zoom = hnmod(&installed, "zoom.hnmod", "zoom", "2.0.0");
|
||||
let local = hnmod(&installed, "mine.hnmod", "mine", "0.1.0");
|
||||
let index = |listed: &[(&str, &Path)], revoked: &str| {
|
||||
let projects: Vec<_> = listed
|
||||
.iter()
|
||||
.map(|(id, path)| serde_json::json!({
|
||||
"id": id, "kind": "native-mod", "name": id, "summary": "", "license": "MIT",
|
||||
"source": "https://x.test", "versions": [{"version": "2.0.0", "api": "native-1",
|
||||
"game": "26.3", "file": {"name": format!("{id}.hnmod"), "url": "file:///x",
|
||||
"size": fs::metadata(path).unwrap().len(), "sha256": sha256(path)}}]
|
||||
}))
|
||||
.collect();
|
||||
let text = serde_json::json!({"schema": 1, "projects": projects,
|
||||
"revoked": if revoked.is_empty() { serde_json::json!([]) }
|
||||
else { serde_json::json!([{"id": revoked, "reason": "r", "date": "2026-11-07"}]) }});
|
||||
horizon_mods::index::Index::parse_local(&text.to_string()).unwrap()
|
||||
};
|
||||
let base = "6".repeat(40);
|
||||
let ask = |index: &horizon_mods::index::Index| {
|
||||
instance_request(&home, "x86_64-unknown-linux-gnu", &base, index).unwrap()
|
||||
};
|
||||
// Nothing turned on: nothing to ask for.
|
||||
assert_eq!(ask(&index(&[("zoom", &zoom)], "")), None);
|
||||
crate::browser::java::set_enabled(&home, "zoom", true).unwrap();
|
||||
let request = ask(&index(&[("zoom", &zoom)], "")).unwrap();
|
||||
assert_eq!(request.mods.len(), 1);
|
||||
assert_eq!(request.mods[0].sha256, sha256(&zoom));
|
||||
// Taken down, or not the file the index lists: built here.
|
||||
assert_eq!(ask(&index(&[("zoom", &zoom)], "zoom")), None);
|
||||
assert_eq!(ask(&index(&[("zoom", &local)], "")), None);
|
||||
// A mod of one's own turned on beside it: built here.
|
||||
crate::browser::java::set_enabled(&home, "mine", true).unwrap();
|
||||
assert_eq!(ask(&index(&[("zoom", &zoom)], "")), None);
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
}
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# Signs one prebuilt native-mod build (#11859, crates/horizon-release-sign/src/native_build.rs) in
|
||||
# horizon-mods' native-build workflow: writes <key16>-<target>.sig beside the archive, one base64 Ed25519
|
||||
# signature per line over "horizon native build <key> <target>\n<sha256> <archive>\n", for every key set in
|
||||
# NATIVE_BUILD_SIGNING_KEY and NATIVE_BUILD_SIGNING_KEY_NEXT (base64 PKCS#8, the `native-builds` environment's
|
||||
# secrets). The key and target are the workflow's own inputs, never read from the build: a build can only be
|
||||
# signed as the set it was asked for. The job that runs this holds the private keys, so it runs nothing from the
|
||||
# build but this file, which calls only sha256sum and OpenSSL.
|
||||
# Each key's public half must be listed in native-build-keys.txt (beside this repository's copy of the script),
|
||||
# else launchers would refuse the file: the script fails instead.
|
||||
# EPHEMERAL=yes (dry runs, tests) signs with a throwaway key instead and writes its public key to ephemeral.pub.
|
||||
# tools/ci/sign_native_build.sh <key, 64 hex> <target> dist/<key16>-<target>.tar.gz
|
||||
set -euo pipefail
|
||||
key=$1 target=$2 archive=$3
|
||||
openssl=${OPENSSL:-openssl}
|
||||
keys_file=${NATIVE_BUILD_KEYS:-$(dirname "$0")/../../crates/horizon-release-sign/native-build-keys.txt}
|
||||
[[ "$key" =~ ^[0-9a-f]{64}$ ]] || { echo "$key: not a build key (64 lowercase hex digits)" >&2; exit 1; }
|
||||
[[ "$target" =~ ^[a-z0-9_]+(-[a-z0-9_]+)+$ ]] || { echo "$target: not a target triple" >&2; exit 1; }
|
||||
dir=$(dirname "$archive")
|
||||
name=$(basename "$archive")
|
||||
[[ "$name" == "${key:0:16}-$target.tar.gz" ]] || { echo "$name: must be named ${key:0:16}-$target.tar.gz" >&2; exit 1; }
|
||||
if command -v sha256sum > /dev/null; then sha=$(sha256sum < "$archive"); else sha=$(shasum -a 256 < "$archive"); fi
|
||||
sha=${sha%% *}
|
||||
umask 077
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
printf 'horizon native build %s %s\n%s %s\n' "$key" "$target" "$sha" "$name" > "$work/message"
|
||||
out="$dir/${key:0:16}-$target.sig"
|
||||
rm -f "$out" "$dir/ephemeral.pub"
|
||||
|
||||
sign_with() { # the key file in $1; $2 = "listed" to require its public key in the keys file
|
||||
"$openssl" pkey -inform DER -in "$1" -pubout -outform DER | tail -c 32 | base64 > "$work/public"
|
||||
if [[ "$2" == listed ]] && ! grep -qxF "$(cat "$work/public")" <(sed 's/[[:space:]]//g' "$keys_file"); then
|
||||
echo "a signing key's public half is not in $(basename "$keys_file"): launchers would refuse this build" >&2
|
||||
exit 1
|
||||
fi
|
||||
"$openssl" pkeyutl -sign -rawin -inkey "$1" -keyform DER -in "$work/message" -out "$work/signature"
|
||||
"$openssl" pkeyutl -verify -rawin -pubin -inkey <("$openssl" pkey -inform DER -in "$1" -pubout) \
|
||||
-in "$work/message" -sigfile "$work/signature" > /dev/null
|
||||
base64 < "$work/signature" | tr -d '\n' >> "$out"
|
||||
echo >> "$out"
|
||||
rm -f "$1" "$work/signature"
|
||||
}
|
||||
|
||||
if [[ -n "${EPHEMERAL:-}" ]]; then
|
||||
"$openssl" genpkey -algorithm ed25519 -outform DER -out "$work/key"
|
||||
"$openssl" pkey -inform DER -in "$work/key" -pubout -outform DER | tail -c 32 | base64 > "$dir/ephemeral.pub"
|
||||
sign_with "$work/key" any
|
||||
else
|
||||
for var in NATIVE_BUILD_SIGNING_KEY NATIVE_BUILD_SIGNING_KEY_NEXT; do
|
||||
if [[ -n "${!var:-}" ]]; then
|
||||
# From the environment into a private temporary file: never an argument, never printed.
|
||||
printf '%s' "${!var}" | base64 --decode > "$work/key"
|
||||
sign_with "$work/key" listed
|
||||
fi
|
||||
done
|
||||
fi
|
||||
[[ -s "$out" ]] || { echo "no signing key: NATIVE_BUILD_SIGNING_KEY is not set" >&2; exit 1; }
|
||||
echo "$name: sha256 $sha, signed with $(wc -l < "$out" | tr -d ' ') key(s) into $(basename "$out")"
|
||||
Reference in new issue
Block a user