mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Launcher: import a game build you already have as its own version
A horizon-client built on a fork's Actions can't start on its own: it has none of the prepared game files. The Versions page gets "Import a game build…", which takes the program (or a zip holding it), checks that it is a Horizon game for this platform, and installs it as imported-<date>-<sha>: a copy of the selected (or newest) release or Nightly's folder with the picked program in place of the game, so it plays with that version's prepared files. It is listed apart as unsigned, can be used and removed like any version, and no update replaces it, prunes it or moves the selection off it. The CLI gets `import-build <path> --trust-unsigned`, kept out of MCP: running a program nothing signed is the player's decision. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
7366f3f7aa
commit
f898dc0bb7
19 files changed
+648
-38
No files matched your search
@@ -44,6 +44,18 @@ Remove an installed version.
|
||||
|
||||
MCP: `remove`, needs `confirm: true`.
|
||||
|
||||
## `horizon-launcher import-build`
|
||||
|
||||
Install a game program you have, such as a horizon-client built on your fork's Actions, as its own version on an installed release's game files, and select it. Nothing signed it: it runs with your rights, and updates never replace it.
|
||||
|
||||
horizon-launcher import-build <PATH>
|
||||
|
||||
- `PATH`: The program (horizon-client, horizon-client.exe on Windows), or a zip holding it
|
||||
- `--base`: The installed release or Nightly whose game files it plays with (default: the selected one, else the newest)
|
||||
- `--trust-unsigned`: You picked this build yourself and trust it with full access to this computer
|
||||
|
||||
MCP: none: the player's switch.
|
||||
|
||||
## `horizon-launcher prepare`
|
||||
|
||||
Prepare a version's resources from your own Minecraft install, or from Mojang.
|
||||
|
||||
+11
-1
@@ -788,5 +788,15 @@
|
||||
"Export the server outside the launcher's managed folders.": "Exportiere den Server außerhalb der vom Launcher verwalteten Ordner.",
|
||||
"{path} is not a folder to build into.": "{path} ist kein Ordner, in den gebaut wird.",
|
||||
"Could not copy the build: {error}": "Build konnte nicht kopiert werden: {error}",
|
||||
"Could not move the build to {path}: {error}": "Build konnte nicht nach {path} verschoben werden: {error}"
|
||||
"Could not move the build to {path}: {error}": "Build konnte nicht nach {path} verschoben werden: {error}",
|
||||
"Import a game build…": "Spiel-Build importieren…",
|
||||
"Importing…": "Wird importiert…",
|
||||
"Imported builds": "Importierte Builds",
|
||||
"Unsigned: you picked these programs yourself, and Horizon can't check where they came from. Updates never replace them.": "Unsigniert: Du hast diese Programme selbst ausgewählt, und Horizon kann nicht prüfen, woher sie stammen. Updates ersetzen sie nie.",
|
||||
"Game files from {base}": "Spieldateien von {base}",
|
||||
"Imported · {date}": "Importiert · {date}",
|
||||
"{file} is not a Horizon game for this computer.": "{file} ist kein Horizon-Spiel für diesen Computer.",
|
||||
"Install a release or Nightly first: an imported build plays with its game files.": "Installiere zuerst ein Release oder eine Nightly: Ein importierter Build nutzt deren Spieldateien.",
|
||||
"{file} holds no {program}.": "{file} enthält kein {program}.",
|
||||
"Could not import the build: {error}": "Der Build konnte nicht importiert werden: {error}"
|
||||
}
|
||||
+11
-1
@@ -788,5 +788,15 @@
|
||||
"Export the server outside the launcher's managed folders.": "Exporte el servidor fuera de las carpetas administradas por el launcher.",
|
||||
"{path} is not a folder to build into.": "{path} no es una carpeta para compilar.",
|
||||
"Could not copy the build: {error}": "No se pudo copiar la compilación: {error}",
|
||||
"Could not move the build to {path}: {error}": "No se pudo mover la compilación a {path}: {error}"
|
||||
"Could not move the build to {path}: {error}": "No se pudo mover la compilación a {path}: {error}",
|
||||
"Import a game build…": "Importar una compilación del juego…",
|
||||
"Importing…": "Importando…",
|
||||
"Imported builds": "Compilaciones importadas",
|
||||
"Unsigned: you picked these programs yourself, and Horizon can't check where they came from. Updates never replace them.": "Sin firmar: elegiste estos programas tú mismo, y Horizon no puede comprobar de dónde vienen. Las actualizaciones nunca los reemplazan.",
|
||||
"Game files from {base}": "Archivos del juego de {base}",
|
||||
"Imported · {date}": "Importada · {date}",
|
||||
"{file} is not a Horizon game for this computer.": "{file} no es un juego de Horizon para este ordenador.",
|
||||
"Install a release or Nightly first: an imported build plays with its game files.": "Instala primero una versión o una Nightly: una compilación importada usa sus archivos del juego.",
|
||||
"{file} holds no {program}.": "{file} no contiene ningún {program}.",
|
||||
"Could not import the build: {error}": "No se pudo importar la compilación: {error}"
|
||||
}
|
||||
+11
-1
@@ -788,5 +788,15 @@
|
||||
"Export the server outside the launcher's managed folders.": "Exportez le serveur en dehors des dossiers gérés par le launcher.",
|
||||
"{path} is not a folder to build into.": "{path} n'est pas un dossier dans lequel compiler.",
|
||||
"Could not copy the build: {error}": "Impossible de copier la compilation : {error}",
|
||||
"Could not move the build to {path}: {error}": "Impossible de déplacer la compilation vers {path} : {error}"
|
||||
"Could not move the build to {path}: {error}": "Impossible de déplacer la compilation vers {path} : {error}",
|
||||
"Import a game build…": "Importer une build du jeu…",
|
||||
"Importing…": "Importation…",
|
||||
"Imported builds": "Builds importées",
|
||||
"Unsigned: you picked these programs yourself, and Horizon can't check where they came from. Updates never replace them.": "Non signées : vous avez choisi ces programmes vous-même, et Horizon ne peut pas vérifier d'où ils viennent. Les mises à jour ne les remplacent jamais.",
|
||||
"Game files from {base}": "Fichiers du jeu de {base}",
|
||||
"Imported · {date}": "Importée · {date}",
|
||||
"{file} is not a Horizon game for this computer.": "{file} n'est pas un jeu Horizon pour cet ordinateur.",
|
||||
"Install a release or Nightly first: an imported build plays with its game files.": "Installez d'abord une version ou une Nightly : une build importée utilise ses fichiers du jeu.",
|
||||
"{file} holds no {program}.": "{file} ne contient aucun {program}.",
|
||||
"Could not import the build: {error}": "Impossible d'importer la build : {error}"
|
||||
}
|
||||
+11
-1
@@ -788,5 +788,15 @@
|
||||
"Export the server outside the launcher's managed folders.": "ランチャーが管理するフォルダーの外にサーバーをエクスポートしてください。",
|
||||
"{path} is not a folder to build into.": "{path} はビルド先のフォルダーではありません。",
|
||||
"Could not copy the build: {error}": "ビルドをコピーできませんでした: {error}",
|
||||
"Could not move the build to {path}: {error}": "ビルドを {path} に移動できませんでした: {error}"
|
||||
"Could not move the build to {path}: {error}": "ビルドを {path} に移動できませんでした: {error}",
|
||||
"Import a game build…": "ゲームのビルドをインポート…",
|
||||
"Importing…": "インポート中…",
|
||||
"Imported builds": "インポートしたビルド",
|
||||
"Unsigned: you picked these programs yourself, and Horizon can't check where they came from. Updates never replace them.": "署名なし:これらのプログラムはあなた自身が選んだもので、Horizon は入手元を確認できません。アップデートで置き換えられることはありません。",
|
||||
"Game files from {base}": "{base} のゲームファイル",
|
||||
"Imported · {date}": "インポート · {date}",
|
||||
"{file} is not a Horizon game for this computer.": "{file} はこのコンピューター用の Horizon のゲームではありません。",
|
||||
"Install a release or Nightly first: an imported build plays with its game files.": "先にリリースか Nightly をインストールしてください。インポートしたビルドはそのゲームファイルを使います。",
|
||||
"{file} holds no {program}.": "{file} に {program} がありません。",
|
||||
"Could not import the build: {error}": "ビルドをインポートできませんでした:{error}"
|
||||
}
|
||||
+11
-1
@@ -788,5 +788,15 @@
|
||||
"Export the server outside the launcher's managed folders.": "Eksportuj serwer poza foldery zarządzane przez launcher.",
|
||||
"{path} is not a folder to build into.": "{path} nie jest folderem do kompilacji.",
|
||||
"Could not copy the build: {error}": "Nie można skopiować kompilacji: {error}",
|
||||
"Could not move the build to {path}: {error}": "Nie można przenieść kompilacji do {path}: {error}"
|
||||
"Could not move the build to {path}: {error}": "Nie można przenieść kompilacji do {path}: {error}",
|
||||
"Import a game build…": "Importuj kompilację gry…",
|
||||
"Importing…": "Importowanie…",
|
||||
"Imported builds": "Zaimportowane kompilacje",
|
||||
"Unsigned: you picked these programs yourself, and Horizon can't check where they came from. Updates never replace them.": "Niepodpisane: te programy wybrano samodzielnie i Horizon nie może sprawdzić, skąd pochodzą. Aktualizacje nigdy ich nie zastępują.",
|
||||
"Game files from {base}": "Pliki gry z {base}",
|
||||
"Imported · {date}": "Zaimportowano · {date}",
|
||||
"{file} is not a Horizon game for this computer.": "{file} nie jest grą Horizon dla tego komputera.",
|
||||
"Install a release or Nightly first: an imported build plays with its game files.": "Najpierw zainstaluj wydanie lub Nightly: zaimportowana kompilacja korzysta z jego plików gry.",
|
||||
"{file} holds no {program}.": "{file} nie zawiera {program}.",
|
||||
"Could not import the build: {error}": "Nie udało się zaimportować kompilacji: {error}"
|
||||
}
|
||||
@@ -788,5 +788,15 @@
|
||||
"Export the server outside the launcher's managed folders.": "Exporte o servidor fora das pastas gerenciadas pelo launcher.",
|
||||
"{path} is not a folder to build into.": "{path} não é uma pasta para criação de build.",
|
||||
"Could not copy the build: {error}": "Não foi possível copiar o build: {error}",
|
||||
"Could not move the build to {path}: {error}": "Não foi possível mover o build para {path}: {error}"
|
||||
"Could not move the build to {path}: {error}": "Não foi possível mover o build para {path}: {error}",
|
||||
"Import a game build…": "Importar uma compilação do jogo…",
|
||||
"Importing…": "Importando…",
|
||||
"Imported builds": "Compilações importadas",
|
||||
"Unsigned: you picked these programs yourself, and Horizon can't check where they came from. Updates never replace them.": "Sem assinatura: você mesmo escolheu esses programas, e o Horizon não consegue verificar de onde eles vieram. As atualizações nunca os substituem.",
|
||||
"Game files from {base}": "Arquivos do jogo de {base}",
|
||||
"Imported · {date}": "Importada · {date}",
|
||||
"{file} is not a Horizon game for this computer.": "{file} não é um jogo do Horizon para este computador.",
|
||||
"Install a release or Nightly first: an imported build plays with its game files.": "Instale primeiro uma versão ou uma Nightly: uma compilação importada usa os arquivos do jogo dela.",
|
||||
"{file} holds no {program}.": "{file} não contém nenhum {program}.",
|
||||
"Could not import the build: {error}": "Não foi possível importar a compilação: {error}"
|
||||
}
|
||||
+11
-1
@@ -788,5 +788,15 @@
|
||||
"Export the server outside the launcher's managed folders.": "Экспортируйте сервер вне управляемых лаунчером папок.",
|
||||
"{path} is not a folder to build into.": "{path} не является папкой для сборки.",
|
||||
"Could not copy the build: {error}": "Не удалось скопировать сборку: {error}",
|
||||
"Could not move the build to {path}: {error}": "Не удалось переместить сборку в {path}: {error}"
|
||||
"Could not move the build to {path}: {error}": "Не удалось переместить сборку в {path}: {error}",
|
||||
"Import a game build…": "Импортировать сборку игры…",
|
||||
"Importing…": "Импорт…",
|
||||
"Imported builds": "Импортированные сборки",
|
||||
"Unsigned: you picked these programs yourself, and Horizon can't check where they came from. Updates never replace them.": "Без подписи: вы сами выбрали эти программы, и Horizon не может проверить, откуда они. Обновления никогда их не заменяют.",
|
||||
"Game files from {base}": "Файлы игры из {base}",
|
||||
"Imported · {date}": "Импортировано · {date}",
|
||||
"{file} is not a Horizon game for this computer.": "{file} — не игра Horizon для этого компьютера.",
|
||||
"Install a release or Nightly first: an imported build plays with its game files.": "Сначала установите релиз или Nightly: импортированная сборка использует его файлы игры.",
|
||||
"{file} holds no {program}.": "В {file} нет {program}.",
|
||||
"Could not import the build: {error}": "Не удалось импортировать сборку: {error}"
|
||||
}
|
||||
+11
-1
@@ -788,5 +788,15 @@
|
||||
"Export the server outside the launcher's managed folders.": "Sunucuyu launcher tarafından yönetilen klasörlerin dışına aktarın.",
|
||||
"{path} is not a folder to build into.": "{path} derleme yapılacak klasör değil.",
|
||||
"Could not copy the build: {error}": "Build kopyalanamadı: {error}",
|
||||
"Could not move the build to {path}: {error}": "Build {path} konumuna taşınamadı: {error}"
|
||||
"Could not move the build to {path}: {error}": "Build {path} konumuna taşınamadı: {error}",
|
||||
"Import a game build…": "Oyun derlemesini içe aktar…",
|
||||
"Importing…": "İçe aktarılıyor…",
|
||||
"Imported builds": "İçe aktarılan derlemeler",
|
||||
"Unsigned: you picked these programs yourself, and Horizon can't check where they came from. Updates never replace them.": "İmzasız: bu programları kendiniz seçtiniz ve Horizon nereden geldiklerini doğrulayamaz. Güncellemeler bunların yerini asla almaz.",
|
||||
"Game files from {base}": "Oyun dosyaları: {base}",
|
||||
"Imported · {date}": "İçe aktarıldı · {date}",
|
||||
"{file} is not a Horizon game for this computer.": "{file}, bu bilgisayar için bir Horizon oyunu değil.",
|
||||
"Install a release or Nightly first: an imported build plays with its game files.": "Önce bir sürüm veya Nightly kurun: içe aktarılan derleme onun oyun dosyalarını kullanır.",
|
||||
"{file} holds no {program}.": "{file} içinde {program} yok.",
|
||||
"Could not import the build: {error}": "Derleme içe aktarılamadı: {error}"
|
||||
}
|
||||
+11
-1
@@ -788,5 +788,15 @@
|
||||
"Export the server outside the launcher's managed folders.": "Експортуйте сервер позаду папок, которыmi керує лаунчер.",
|
||||
"{path} is not a folder to build into.": "{path} не є папкою для збірки.",
|
||||
"Could not copy the build: {error}": "Не вдалося скопіювати збірку: {error}",
|
||||
"Could not move the build to {path}: {error}": "Не вдалося перемістити збірку у {path}: {error}"
|
||||
"Could not move the build to {path}: {error}": "Не вдалося перемістити збірку у {path}: {error}",
|
||||
"Import a game build…": "Імпортувати збірку гри…",
|
||||
"Importing…": "Імпорт…",
|
||||
"Imported builds": "Імпортовані збірки",
|
||||
"Unsigned: you picked these programs yourself, and Horizon can't check where they came from. Updates never replace them.": "Без підпису: ви самі вибрали ці програми, і Horizon не може перевірити, звідки вони. Оновлення ніколи їх не замінюють.",
|
||||
"Game files from {base}": "Файли гри з {base}",
|
||||
"Imported · {date}": "Імпортовано · {date}",
|
||||
"{file} is not a Horizon game for this computer.": "{file} — не гра Horizon для цього комп'ютера.",
|
||||
"Install a release or Nightly first: an imported build plays with its game files.": "Спершу встановіть реліз або Nightly: імпортована збірка використовує його файли гри.",
|
||||
"{file} holds no {program}.": "У {file} немає {program}.",
|
||||
"Could not import the build: {error}": "Не вдалося імпортувати збірку: {error}"
|
||||
}
|
||||
+11
-1
@@ -788,5 +788,15 @@
|
||||
"Export the server outside the launcher's managed folders.": "将服务器导出到启动器管理的文件夹之外。",
|
||||
"{path} is not a folder to build into.": "{path} 不是可供构建的文件夹。",
|
||||
"Could not copy the build: {error}": "无法复制构建:{error}",
|
||||
"Could not move the build to {path}: {error}": "无法将构建移动到 {path}:{error}"
|
||||
"Could not move the build to {path}: {error}": "无法将构建移动到 {path}:{error}",
|
||||
"Import a game build…": "导入游戏构建…",
|
||||
"Importing…": "正在导入…",
|
||||
"Imported builds": "导入的构建",
|
||||
"Unsigned: you picked these programs yourself, and Horizon can't check where they came from. Updates never replace them.": "未签名:这些程序是你自己选择的,Horizon 无法确认它们的来源。更新永远不会替换它们。",
|
||||
"Game files from {base}": "游戏文件来自 {base}",
|
||||
"Imported · {date}": "已导入 · {date}",
|
||||
"{file} is not a Horizon game for this computer.": "{file} 不是适用于这台电脑的 Horizon 游戏。",
|
||||
"Install a release or Nightly first: an imported build plays with its game files.": "请先安装一个正式版或 Nightly:导入的构建会使用它的游戏文件。",
|
||||
"{file} holds no {program}.": "{file} 中没有 {program}。",
|
||||
"Could not import the build: {error}": "无法导入构建:{error}"
|
||||
}
|
||||
+17
-1
@@ -1163,6 +1163,14 @@ fn reuse_prepared(versions: &Path, tag: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// What an install does after its folder is in place, for a version put there another way (an
|
||||
/// imported build, `imported`): another installed version's resources if it has none, and
|
||||
/// whatever its resources lack.
|
||||
pub(crate) fn reuse_prepared_for(tag: &str) {
|
||||
reuse_prepared(&versions_dir(), tag);
|
||||
complete_assets(tag);
|
||||
}
|
||||
|
||||
/// `reuse_prepared` for every installed version that is not prepared, oldest first, so a
|
||||
/// version prepared this way can serve the next. Launchers before 0.1.4 installed new versions
|
||||
/// without it; this catches those up. Returns each (version, where its resources came from).
|
||||
@@ -2609,11 +2617,13 @@ pub fn check_updates() -> Result<Updates, String> {
|
||||
/// Whether a version installed as an update becomes the selected one: when the player had none,
|
||||
/// was on a Nightly, or on the newest installed release (so a player of the release channel moves
|
||||
/// to the Nightly at the first check, docs/release-flow.md). An older release picked by hand
|
||||
/// stays; an instance pinned to a version keeps it either way.
|
||||
/// stays, and so does an imported build (`imported`); an instance pinned to a version keeps it
|
||||
/// either way.
|
||||
pub fn select_after_update(selected: Option<&str>, installed_before: &[String]) -> bool {
|
||||
match selected {
|
||||
None => true,
|
||||
Some(s) if crate::nightly::date_of(s).is_some() => true,
|
||||
Some(s) if crate::imported::is_imported(s) => false,
|
||||
Some(s) => installed_before
|
||||
.iter()
|
||||
.find(|t| is_game_tag(t))
|
||||
@@ -3101,6 +3111,8 @@ pub enum Msg {
|
||||
NativeProgress(String),
|
||||
/// The native-mods build ended: the version built, or why not.
|
||||
Native(Result<String, String>),
|
||||
/// An imported game build is installed (`imported::import`): its version, or why not.
|
||||
Imported(Result<String, String>),
|
||||
/// A Java instance's game is ready to start (`java_game`, wrapped): the instance's id and
|
||||
/// game folder, and the command or why not.
|
||||
/// A Java instance's game ready to start, and its variables for the post-exit command.
|
||||
@@ -4328,6 +4340,10 @@ pub fn {f}("
|
||||
assert!(select_after_update(Some("v2026.10.12"), &installed));
|
||||
assert!(!select_after_update(Some("v2026.10.1"), &installed));
|
||||
assert!(select_after_update(None, &installed));
|
||||
assert!(!select_after_update(
|
||||
Some("imported-2026-11-01-abcdef0"),
|
||||
&["imported-2026-11-01-abcdef0".to_string()]
|
||||
));
|
||||
}
|
||||
|
||||
/// The Nightly is the only build Horizon ships (docs/release-flow.md): every launcher takes the
|
||||
|
||||
@@ -5,6 +5,7 @@ use horizon_launcher::account::{self, Accounts};
|
||||
use horizon_launcher::backend::{self, Release};
|
||||
use horizon_launcher::github_mods;
|
||||
use horizon_launcher::i18n::{tr, trf};
|
||||
use horizon_launcher::imported;
|
||||
use horizon_launcher::instances;
|
||||
use horizon_launcher::loaders;
|
||||
use horizon_launcher::native_mods;
|
||||
@@ -708,6 +709,20 @@ enum Cmd {
|
||||
Install { tag: String },
|
||||
/// Remove an installed version
|
||||
Remove { tag: String },
|
||||
/// Install a game program you have, such as a horizon-client built on your fork's Actions,
|
||||
/// as its own version on an installed release's game files, and select it. Nothing signed
|
||||
/// it: it runs with your rights, and updates never replace it
|
||||
ImportBuild {
|
||||
/// The program (horizon-client, horizon-client.exe on Windows), or a zip holding it
|
||||
path: PathBuf,
|
||||
/// The installed release or Nightly whose game files it plays with (default: the
|
||||
/// selected one, else the newest)
|
||||
#[arg(long)]
|
||||
base: Option<String>,
|
||||
/// You picked this build yourself and trust it with full access to this computer
|
||||
#[arg(long)]
|
||||
trust_unsigned: bool,
|
||||
},
|
||||
/// Prepare a version's resources from your own Minecraft install, or from Mojang
|
||||
Prepare {
|
||||
/// The game folder of the official launcher, Prism Launcher, MultiMC, the Modrinth App,
|
||||
@@ -1333,6 +1348,24 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
|
||||
backend::remove_version(&tag)?;
|
||||
println!("Removed {tag}.");
|
||||
}
|
||||
Cmd::ImportBuild {
|
||||
path,
|
||||
base,
|
||||
trust_unsigned,
|
||||
} => {
|
||||
if !trust_unsigned {
|
||||
return Err("An imported build is unsigned: Horizon can't check where it came from. If you trust it, pass --trust-unsigned.".into());
|
||||
}
|
||||
let tag = imported::import(&path, base.as_deref())?;
|
||||
let prepared = backend::is_prepared(&tag);
|
||||
if as_json {
|
||||
json(serde_json::json!({ "imported": tag, "prepared": prepared }));
|
||||
} else if prepared {
|
||||
println!("Imported {tag} and selected it. It is prepared already, from another installed version.");
|
||||
} else {
|
||||
println!("Imported {tag} and selected it. Prepare it next: horizon-launcher prepare --version {tag}");
|
||||
}
|
||||
}
|
||||
Cmd::Prepare {
|
||||
minecraft,
|
||||
download,
|
||||
|
||||
@@ -15,6 +15,8 @@ const NOT_TOOLS: &[&str] = &[
|
||||
"agent-config",
|
||||
"no-ai",
|
||||
"native-build",
|
||||
// It installs a program nothing signed: the player's decision.
|
||||
"import-build",
|
||||
// They install native code nobody reviewed, and say it first: the player's decision.
|
||||
"mods add-github",
|
||||
"mods update-github",
|
||||
|
||||
@@ -37,6 +37,7 @@ impl Launcher {
|
||||
what.push(format!(
|
||||
"Horizon {}",
|
||||
horizon_launcher::nightly::label(tag)
|
||||
.or_else(|| horizon_launcher::imported::label(tag))
|
||||
.unwrap_or_else(|| tag.trim_start_matches('v').to_string())
|
||||
));
|
||||
}
|
||||
@@ -317,6 +318,7 @@ impl Launcher {
|
||||
.font_weight(FontWeight::BOLD)
|
||||
.child(
|
||||
horizon_launcher::nightly::label(tag)
|
||||
.or_else(|| horizon_launcher::imported::label(tag))
|
||||
.unwrap_or_else(|| tag.trim_start_matches('v').to_string()),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
//! Game builds the player imports: a `horizon-client` they already have (one built on their
|
||||
//! fork's Actions, to try a merged change before the next Nightly), installed as its own
|
||||
//! version beside the releases. The program alone can't start: it needs a version folder's
|
||||
//! game files. So an import is a copy of an installed release or Nightly's folder (its
|
||||
//! research-lock.json, preparer sources and prepared resources) with the picked program in
|
||||
//! place of that version's own, as a native build is (`native_mods::install`).
|
||||
//!
|
||||
//! Nothing signed it: the Versions page says so. Its tag (`imported-<date>-<sha>`) is no
|
||||
//! release's, so no update replaces it, prunes it or moves the selection off it.
|
||||
use crate::backend::{self, CLIENT};
|
||||
use crate::i18n::{tr, trf};
|
||||
use crate::native_mods::{self, Target};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
const PREFIX: &str = "imported-";
|
||||
/// What an import was made from, in its folder.
|
||||
const RECORD: &str = "imported.json";
|
||||
/// The most a program unpacked from a zip may take: a game build is ~100 MB.
|
||||
const MAX_PROGRAM: u64 = 1 << 30;
|
||||
|
||||
/// Whether an installed version is an imported build.
|
||||
pub fn is_imported(version: &str) -> bool {
|
||||
version.starts_with(PREFIX)
|
||||
}
|
||||
|
||||
/// An imported build as a player reads it: `Imported · 2026-11-01`, or None for any other version.
|
||||
pub fn label(version: &str) -> Option<String> {
|
||||
let date = version.strip_prefix(PREFIX)?.get(..10)?;
|
||||
Some(trf("Imported · {date}", &[("date", date)]))
|
||||
}
|
||||
|
||||
/// What an imported build was made from: its `imported.json`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct About {
|
||||
/// The release or Nightly whose game files it plays with.
|
||||
pub base: String,
|
||||
/// The file the player picked.
|
||||
pub file: String,
|
||||
}
|
||||
|
||||
/// What installed imported build `version` was made from; None for any other version.
|
||||
pub fn about(version: &str) -> Option<About> {
|
||||
if !is_imported(version) {
|
||||
return None;
|
||||
}
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(backend::version_dir(version).join(RECORD)).ok()?)
|
||||
.ok()?;
|
||||
let field = |name: &str| json[name].as_str().map(str::to_owned);
|
||||
Some(About {
|
||||
base: field("base")?,
|
||||
file: field("file")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// A version Horizon published and signed: a release or a Nightly.
|
||||
fn official(version: &str) -> bool {
|
||||
backend::is_game_tag(version) || crate::nightly::date_of(version).is_some()
|
||||
}
|
||||
|
||||
/// The installed version an import takes its game files from: `selected` when Horizon published
|
||||
/// it, else the newest installed release or Nightly (`installed` is newest first).
|
||||
pub fn base_for(installed: &[String], selected: Option<&str>) -> Option<String> {
|
||||
selected
|
||||
.filter(|s| official(s) && installed.iter().any(|t| t == s))
|
||||
.map(str::to_owned)
|
||||
.or_else(|| installed.iter().find(|t| official(t)).cloned())
|
||||
}
|
||||
|
||||
/// Installs the game program at `path` (or the one inside a zip there) as a new version on the
|
||||
/// game files of `base` (default: [`base_for`]), prepared from another version where it can be,
|
||||
/// and selects it. Importing the same program again selects the version it made before.
|
||||
pub fn import(path: &Path, base: Option<&str>) -> Result<String, String> {
|
||||
let file = path.file_name().map_or_else(
|
||||
|| path.display().to_string(),
|
||||
|n| n.to_string_lossy().into_owned(),
|
||||
);
|
||||
let program = read_program(path, &file)?;
|
||||
if !runs_here(&program) || horizon_mods::api_level::read_mark(&program).is_none() {
|
||||
return Err(trf(
|
||||
"{file} is not a Horizon game for this computer.",
|
||||
&[("file", &file)],
|
||||
));
|
||||
}
|
||||
let installed = backend::installed();
|
||||
let selected = backend::Settings::load().selected;
|
||||
let base = match base {
|
||||
Some(b) if official(b) && installed.iter().any(|t| t == b) => b.to_owned(),
|
||||
Some(b) => return Err(format!("{b} is not an installed release or Nightly.")),
|
||||
None => base_for(&installed, selected.as_deref()).ok_or_else(|| {
|
||||
tr("Install a release or Nightly first: an imported build plays with its game files.")
|
||||
.to_string()
|
||||
})?,
|
||||
};
|
||||
let date = chrono::Local::now().format("%Y-%m-%d").to_string();
|
||||
let tag = install_in(&backend::version_dir(""), &base, &program, &file, &date)?;
|
||||
backend::reuse_prepared_for(&tag);
|
||||
// Read again: the window may have saved other settings during the copy.
|
||||
let mut settings = backend::Settings::load();
|
||||
settings.selected = Some(tag.clone());
|
||||
settings.save();
|
||||
Ok(tag)
|
||||
}
|
||||
|
||||
/// The program at `path`: the file itself, or from a zip (a GitHub Actions artifact), its one
|
||||
/// entry named as the game is on this platform (`CLIENT`), in any folder.
|
||||
fn read_program(path: &Path, file: &str) -> Result<Vec<u8>, String> {
|
||||
let open = fs::File::open(path).map_err(|e| format!("{file}: {e}"))?;
|
||||
let Ok(mut zip) = zip::ZipArchive::new(open) else {
|
||||
return fs::read(path).map_err(|e| format!("{file}: {e}"));
|
||||
};
|
||||
for i in 0..zip.len() {
|
||||
let mut entry = zip.by_index(i).map_err(|e| format!("{file}: {e}"))?;
|
||||
let named = entry
|
||||
.enclosed_name()
|
||||
.is_some_and(|p| p.file_name() == Some(std::ffi::OsStr::new(CLIENT)));
|
||||
if named && entry.is_file() {
|
||||
let mut program = Vec::new();
|
||||
(&mut entry)
|
||||
.take(MAX_PROGRAM)
|
||||
.read_to_end(&mut program)
|
||||
.map_err(|e| format!("{file}: {e}"))?;
|
||||
return Ok(program);
|
||||
}
|
||||
}
|
||||
Err(trf(
|
||||
"{file} holds no {program}.",
|
||||
&[("file", file), ("program", CLIENT)],
|
||||
))
|
||||
}
|
||||
|
||||
/// Whether `program` starts as a program of this platform does: PE on Windows, Mach-O on macOS,
|
||||
/// ELF elsewhere. A Linux build picked on Windows would only fail to start.
|
||||
fn runs_here(program: &[u8]) -> bool {
|
||||
let magic: &[&[u8]] = if cfg!(windows) {
|
||||
&[b"MZ"]
|
||||
} else if cfg!(target_os = "macos") {
|
||||
// 64-bit Mach-O, and a universal binary.
|
||||
&[&[0xcf, 0xfa, 0xed, 0xfe], &[0xca, 0xfe, 0xba, 0xbe]]
|
||||
} else {
|
||||
&[b"\x7fELF"]
|
||||
};
|
||||
magic.iter().any(|m| program.starts_with(m))
|
||||
}
|
||||
|
||||
/// Installs `program` in `versions` as a copy of installed version `base` with it in place of
|
||||
/// `base`'s game, and returns its tag. Copied beside it and renamed when complete, as an
|
||||
/// install is: an interrupted import is never installed.
|
||||
fn install_in(
|
||||
versions: &Path,
|
||||
base: &str,
|
||||
program: &[u8],
|
||||
file: &str,
|
||||
date: &str,
|
||||
) -> Result<String, String> {
|
||||
let sha256: String = Sha256::digest(program)
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect();
|
||||
let tag = format!("{PREFIX}{date}-{}", &sha256[..7]);
|
||||
let dir = versions.join(&tag);
|
||||
if dir.join(".installed").exists() {
|
||||
return Ok(tag);
|
||||
}
|
||||
let partial = versions.join(format!(".{tag}.partial"));
|
||||
let _ = fs::remove_dir_all(&partial);
|
||||
fs::create_dir_all(&partial).map_err(|e| e.to_string())?;
|
||||
let record = serde_json::json!({ "base": base, "file": file, "sha256": sha256 });
|
||||
let copied = fill(&versions.join(base), &partial, program, &record).and_then(|()| {
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::rename(&partial, &dir)?;
|
||||
fs::write(dir.join(".installed"), "")
|
||||
});
|
||||
if let Err(e) = copied {
|
||||
let _ = fs::remove_dir_all(&partial);
|
||||
return Err(trf(
|
||||
"Could not import the build: {error}",
|
||||
&[("error", &e.to_string())],
|
||||
));
|
||||
}
|
||||
Ok(tag)
|
||||
}
|
||||
|
||||
/// Fills the new version's folder `to`: version folder `base`'s files but its game and its
|
||||
/// record (`native_mods::carried_over`, cloned where the disk can), `program` as the game, and
|
||||
/// what it was made from.
|
||||
fn fill(base: &Path, to: &Path, program: &[u8], record: &serde_json::Value) -> std::io::Result<()> {
|
||||
for entry in fs::read_dir(base)?.flatten() {
|
||||
let name = entry.file_name();
|
||||
if !native_mods::carried_over(&name, Target::Client) || name == RECORD {
|
||||
continue;
|
||||
}
|
||||
let target = to.join(&name);
|
||||
if entry.path().is_dir() {
|
||||
backend::copy_dir(&entry.path(), &target)?;
|
||||
} else {
|
||||
horizon_prepare::clone_or_copy(&entry.path(), &target)?;
|
||||
fs::set_permissions(&target, entry.metadata()?.permissions())?;
|
||||
}
|
||||
}
|
||||
fs::write(to.join(CLIENT), program)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(to.join(CLIENT), fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
fs::write(to.join(RECORD), record.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dir(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("horizon-import-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// A program as this platform's are, carrying the mod API mark the game's do.
|
||||
fn program(body: &[u8]) -> Vec<u8> {
|
||||
let magic: &[u8] = if cfg!(windows) {
|
||||
b"MZ"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
&[0xcf, 0xfa, 0xed, 0xfe]
|
||||
} else {
|
||||
b"\x7fELF"
|
||||
};
|
||||
[magic, body, &horizon_mods::api_level::MARK[..]].concat()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_import_is_its_own_version_on_its_bases_game_files() {
|
||||
let versions = dir("install");
|
||||
let base = versions.join("nightly-2026-11-01-c9d474d");
|
||||
fs::create_dir_all(base.join(".local/horizon-client/26.3")).unwrap();
|
||||
fs::write(
|
||||
base.join(".local/horizon-client/26.3/horizon-resources.json"),
|
||||
b"{}",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(base.join("research-lock.json"), b"lock").unwrap();
|
||||
fs::write(base.join(CLIENT), b"signed").unwrap();
|
||||
fs::write(base.join(".installed"), b"").unwrap();
|
||||
fs::create_dir_all(base.join("Horizon.app")).unwrap();
|
||||
let built = program(b"fork build");
|
||||
let tag = install_in(
|
||||
&versions,
|
||||
"nightly-2026-11-01-c9d474d",
|
||||
&built,
|
||||
CLIENT,
|
||||
"2026-11-01",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
tag.starts_with("imported-2026-11-01-") && tag.len() == 27,
|
||||
"{tag}"
|
||||
);
|
||||
assert!(is_imported(&tag) && !official(&tag));
|
||||
assert_eq!(label(&tag).as_deref(), Some("Imported · 2026-11-01"));
|
||||
let dir = versions.join(&tag);
|
||||
assert_eq!(fs::read(dir.join(CLIENT)).unwrap(), built);
|
||||
assert_eq!(fs::read(base.join(CLIENT)).unwrap(), b"signed");
|
||||
assert!(dir.join(".installed").is_file());
|
||||
assert!(dir
|
||||
.join(".local/horizon-client/26.3/horizon-resources.json")
|
||||
.is_file());
|
||||
assert_eq!(fs::read(dir.join("research-lock.json")).unwrap(), b"lock");
|
||||
// The macOS app is made again from the imported program (game_app.rs).
|
||||
assert!(!dir.join("Horizon.app").exists());
|
||||
let record: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(dir.join(RECORD)).unwrap()).unwrap();
|
||||
assert_eq!(record["base"], "nightly-2026-11-01-c9d474d");
|
||||
// The same program again is the same version, left as it is.
|
||||
let again = install_in(
|
||||
&versions,
|
||||
"nightly-2026-11-01-c9d474d",
|
||||
&built,
|
||||
"x",
|
||||
"2026-11-01",
|
||||
);
|
||||
assert_eq!(again.unwrap(), tag);
|
||||
assert_eq!(fs::read_dir(&versions).unwrap().count(), 2);
|
||||
let _ = fs::remove_dir_all(versions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_horizon_game_for_this_computer_is_imported() {
|
||||
assert!(runs_here(&program(b"")));
|
||||
let other = if cfg!(windows) { b"\x7fELF" } else { b"MZ\0\0" };
|
||||
assert!(!runs_here(other));
|
||||
let d = dir("zip");
|
||||
let zip_path = d.join("horizon-client-windows-x86_64.zip");
|
||||
let mut zip = zip::ZipWriter::new(fs::File::create(&zip_path).unwrap());
|
||||
let options = zip::write::SimpleFileOptions::default();
|
||||
zip.start_file("README.md", options).unwrap();
|
||||
std::io::Write::write_all(&mut zip, b"notes").unwrap();
|
||||
zip.start_file(format!("horizon/{CLIENT}"), options)
|
||||
.unwrap();
|
||||
std::io::Write::write_all(&mut zip, &program(b"zipped")).unwrap();
|
||||
zip.finish().unwrap();
|
||||
assert_eq!(
|
||||
read_program(&zip_path, "a.zip").unwrap(),
|
||||
program(b"zipped")
|
||||
);
|
||||
let plain = d.join("plain");
|
||||
fs::write(&plain, program(b"plain")).unwrap();
|
||||
assert_eq!(read_program(&plain, "plain").unwrap(), program(b"plain"));
|
||||
let empty = d.join("empty.zip");
|
||||
let mut zip = zip::ZipWriter::new(fs::File::create(&empty).unwrap());
|
||||
zip.start_file("README.md", options).unwrap();
|
||||
zip.finish().unwrap();
|
||||
assert!(read_program(&empty, "empty.zip").is_err());
|
||||
let _ = fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_base_is_the_selected_release_or_the_newest_one() {
|
||||
let installed = [
|
||||
"nightly-2026-11-01-c9d474d".to_string(),
|
||||
"v0.2.95".to_string(),
|
||||
"native-c9d474d-0123456789ab".to_string(),
|
||||
"imported-2026-10-31-abcdef0".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
base_for(&installed, Some("v0.2.95")).as_deref(),
|
||||
Some("v0.2.95")
|
||||
);
|
||||
for selected in [None, Some("imported-2026-10-31-abcdef0"), Some("v0.1.0")] {
|
||||
assert_eq!(
|
||||
base_for(&installed, selected).as_deref(),
|
||||
Some("nightly-2026-11-01-c9d474d")
|
||||
);
|
||||
}
|
||||
assert_eq!(base_for(&installed[2..], None), None);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub mod github_mods;
|
||||
pub mod heads;
|
||||
pub mod i18n;
|
||||
pub mod import;
|
||||
pub mod imported;
|
||||
pub mod instances;
|
||||
pub mod java;
|
||||
pub mod java_launch;
|
||||
|
||||
+129
-26
@@ -65,6 +65,7 @@ use gpui::{
|
||||
use horizon_launcher::account::{self, Accounts, SignIn, SignInState};
|
||||
use horizon_launcher::backend::{self, Job, Msg, Release, Running, Settings, Updates};
|
||||
use horizon_launcher::i18n::{self, tr, trf};
|
||||
use horizon_launcher::imported;
|
||||
use horizon_launcher::instances;
|
||||
use horizon_launcher::native_mods;
|
||||
use horizon_launcher::native_status;
|
||||
@@ -192,6 +193,10 @@ struct Launcher {
|
||||
disk: Vec<(String, u64)>,
|
||||
disk_for: Vec<String>,
|
||||
install_error: Option<(String, String)>,
|
||||
/// Import a game build (`imported`, on the Versions page): whether one is being copied in,
|
||||
/// and why the last one failed.
|
||||
importing: bool,
|
||||
import_error: Option<String>,
|
||||
/// Native mods (`native_mods`, on the Mods page): the build's step while it runs, why it
|
||||
/// failed, and the warning before a build while it is open, with the mods it names.
|
||||
native_step: Option<String>,
|
||||
@@ -392,23 +397,9 @@ impl Launcher {
|
||||
// Counts the time a partner banner is on screen, until it may be hidden.
|
||||
cx.spawn(async move |this, cx| loop {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_secs(10))
|
||||
.timer(Duration::from_millis(80))
|
||||
.await;
|
||||
let counting = this.update(cx, |this, cx| {
|
||||
let s = &mut this.settings;
|
||||
if s.partner_hidden || s.partner_seen_secs >= partner::SEEN_BEFORE_HIDE {
|
||||
return false;
|
||||
}
|
||||
// On screen on every page: the card in the sidebar, the banner on Servers.
|
||||
s.partner_seen_secs += 10;
|
||||
if s.partner_seen_secs % 60 == 0 || s.partner_seen_secs >= partner::SEEN_BEFORE_HIDE
|
||||
{
|
||||
s.save();
|
||||
cx.notify();
|
||||
}
|
||||
true
|
||||
});
|
||||
if !matches!(counting, Ok(true)) {
|
||||
if this.update(cx, |this, cx| this.drain(cx)).is_err() {
|
||||
break;
|
||||
}
|
||||
})
|
||||
@@ -469,6 +460,8 @@ impl Launcher {
|
||||
disk: Vec::new(),
|
||||
disk_for: Vec::new(),
|
||||
install_error: None,
|
||||
importing: false,
|
||||
import_error: None,
|
||||
native_step: None,
|
||||
native_started: None,
|
||||
native_done: None,
|
||||
@@ -1030,6 +1023,15 @@ impl Launcher {
|
||||
}
|
||||
}
|
||||
Msg::DiskUse(disk) => self.disk = disk,
|
||||
Msg::Imported(result) => {
|
||||
self.importing = false;
|
||||
self.installed = backend::installed();
|
||||
match result {
|
||||
// `imported::import` saved the selection; this keeps the window's copy.
|
||||
Ok(tag) => self.settings.selected = Some(tag),
|
||||
Err(e) => self.import_error = Some(e),
|
||||
}
|
||||
}
|
||||
Msg::Pruned(gone) => {
|
||||
if !gone.is_empty() {
|
||||
self.installed = backend::installed();
|
||||
@@ -1302,6 +1304,38 @@ impl Launcher {
|
||||
});
|
||||
}
|
||||
|
||||
/// Asks for a game program (or a zip holding one) and installs it as its own version on a
|
||||
/// thread: a version is thousands of files. `Msg::Imported` ends it.
|
||||
fn import_build(&mut self, cx: &mut Context<Self>) {
|
||||
if self.importing {
|
||||
return;
|
||||
}
|
||||
let paths = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
prompt: Some(tr("Import").into()),
|
||||
});
|
||||
cx.spawn(async move |this, cx| {
|
||||
let Ok(Ok(Some(mut paths))) = paths.await else {
|
||||
return;
|
||||
};
|
||||
let Some(path) = paths.pop() else {
|
||||
return;
|
||||
};
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
this.importing = true;
|
||||
this.import_error = None;
|
||||
let tx = this.tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(Msg::Imported(imported::import(&path, None)));
|
||||
});
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Prepares the selected version, its Minecraft files from the player's install or, for an
|
||||
/// owner who asks, from Mojang.
|
||||
fn prepare(&mut self, from: backend::MinecraftFrom) {
|
||||
@@ -1694,6 +1728,7 @@ fn version_tag(tag: &str) -> Div {
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.child(
|
||||
horizon_launcher::nightly::label(tag)
|
||||
.or_else(|| imported::label(tag))
|
||||
.unwrap_or_else(|| tag.trim_start_matches('v').to_string()),
|
||||
)
|
||||
}
|
||||
@@ -2611,7 +2646,11 @@ impl Launcher {
|
||||
// "Nightly · ba6344a", a release's tag: the menu lists the full tags.
|
||||
let label = self.selected().map_or_else(
|
||||
|| tr("No version").into(),
|
||||
|tag| horizon_launcher::nightly::label(tag).unwrap_or_else(|| tag.clone()),
|
||||
|tag| {
|
||||
horizon_launcher::nightly::label(tag)
|
||||
.or_else(|| imported::label(tag))
|
||||
.unwrap_or_else(|| tag.clone())
|
||||
},
|
||||
);
|
||||
// As tall as Play: "Version" over the tag, and a chevron.
|
||||
let n = t.night;
|
||||
@@ -3994,8 +4033,9 @@ impl Launcher {
|
||||
)
|
||||
}
|
||||
|
||||
/// One installed native build on the Versions page: what it was built on, and Use and Remove.
|
||||
fn native_build_row(
|
||||
/// One installed build made here on the Versions page, a native build or an imported one:
|
||||
/// what it was made from, and Use and Remove.
|
||||
fn local_build_row(
|
||||
&self,
|
||||
tag: &str,
|
||||
on_disk: &dyn Fn(&String) -> Option<u64>,
|
||||
@@ -4007,6 +4047,11 @@ impl Launcher {
|
||||
if let Some(about) = native_mods::about(tag) {
|
||||
meta.push(trf("Built on {base}", &[("base", &about.base)]));
|
||||
}
|
||||
if let Some(about) = imported::about(tag) {
|
||||
let base = horizon_launcher::nightly::label(&about.base).unwrap_or(about.base);
|
||||
meta.push(trf("Game files from {base}", &[("base", &base)]));
|
||||
meta.push(about.file);
|
||||
}
|
||||
if let Some(n) = on_disk(&tag.to_string()) {
|
||||
meta.push(trf("{size} on disk", &[("size", &megabytes(n))]));
|
||||
}
|
||||
@@ -4091,17 +4136,45 @@ impl Launcher {
|
||||
.map(|(_, n)| *n)
|
||||
};
|
||||
let used: u64 = self.installed.iter().filter_map(on_disk).sum();
|
||||
// The list's own action at the end of the line above it: a build the player has.
|
||||
let import: AnyElement = if self.importing {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap(px(10.))
|
||||
.w(px(180.))
|
||||
.child(progress_bar(None, &t))
|
||||
.child(
|
||||
text(theme::MICRO)
|
||||
.text_color(t.muted)
|
||||
.child(tr("Importing…")),
|
||||
)
|
||||
.into_any_element()
|
||||
} else {
|
||||
button("import-build", "Import a game build…", Kind::Secondary, &t)
|
||||
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| this.import_build(cx)))
|
||||
.into_any_element()
|
||||
};
|
||||
let page = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(32.))
|
||||
.child(header)
|
||||
.children((used > 0).then(|| {
|
||||
text(theme::UI).text_color(t.muted).child(trf(
|
||||
"Installed versions use {size}.",
|
||||
&[("size", &megabytes(used))],
|
||||
))
|
||||
}));
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap(px(16.))
|
||||
.child(text(theme::UI).text_color(t.muted).when(used > 0, |d| {
|
||||
d.child(trf(
|
||||
"Installed versions use {size}.",
|
||||
&[("size", &megabytes(used))],
|
||||
))
|
||||
}))
|
||||
.child(import),
|
||||
)
|
||||
.children(self.import_error.clone().map(|e| notice(e, &t)));
|
||||
// Native-mods builds are made here, not downloaded: they are listed apart from the releases.
|
||||
let native: Vec<String> = self
|
||||
.installed
|
||||
@@ -4120,7 +4193,37 @@ impl Launcher {
|
||||
.children(
|
||||
native
|
||||
.iter()
|
||||
.map(|tag| self.native_build_row(tag, &on_disk, cx)),
|
||||
.map(|tag| self.local_build_row(tag, &on_disk, cx)),
|
||||
),
|
||||
)
|
||||
});
|
||||
// Imported builds: programs the player picked, which nothing signed.
|
||||
let imports: Vec<String> = self
|
||||
.installed
|
||||
.iter()
|
||||
.chain(&self.removing)
|
||||
.filter(|v| imported::is_imported(v))
|
||||
.cloned()
|
||||
.collect();
|
||||
let page = page.when(!imports.is_empty(), |page| {
|
||||
page.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(8.))
|
||||
.child(caps(tr("Imported builds")).text_color(t.muted))
|
||||
.child(notice(
|
||||
tr(
|
||||
"Unsigned: you picked these programs yourself, and Horizon can't \
|
||||
check where they came from. Updates never replace them.",
|
||||
)
|
||||
.to_string(),
|
||||
&t,
|
||||
))
|
||||
.children(
|
||||
imports
|
||||
.iter()
|
||||
.map(|tag| self.local_build_row(tag, &on_disk, cx)),
|
||||
),
|
||||
)
|
||||
});
|
||||
|
||||
@@ -1758,7 +1758,7 @@ fn install(
|
||||
/// new build's steps write again) are the new build's own: a mod turned off leaves none of its
|
||||
/// data behind. A server build is the headless engine: it keeps neither the game's own binary
|
||||
/// nor any `horizon-engine` the base shipped, which would run unmodded beside its own.
|
||||
fn carried_over(name: &std::ffi::OsStr, target: Target) -> bool {
|
||||
pub(crate) fn carried_over(name: &std::ffi::OsStr, target: Target) -> bool {
|
||||
let mut own: Vec<String> = ["Horizon.app", "native-mods.json", "native-data"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
|
||||
Reference in new issue
Block a user