feat(installer): самодостаточный exe для Windows

Владелец: «для винды я думаю нужен exe». Справедливо — «склонируй
репозиторий и собери» это не установка.

HermesHubSetup.exe требовал, чтобы рядом лежали src/, launcher/, assets/,
config/ и scripts/: PerformInstall берёт их из sourceRoot. Поэтому одного
файла не хватало, и на целевую машину пришлось бы копировать репозиторий.

Теперь содержимое упаковывается при сборке и вшивается в exe ресурсом
(/resource:payload.zip,payload). Если рядом с exe и уровнем выше
исходников нет, установщик распаковывает вшитое во временный каталог и
работает с ним. Прежнее поведение сохранено: при запуске из репозитория
используются файлы на диске, ресурс не трогается.

Проверено исполнением: exe скопирован в пустой каталог, из него извлечены
assets, config, launcher, scripts, src; src/antigravity_provider и
launcher/HermesHubWeb.exe на месте. Размер 11.78 МБ.

Тест сборки установщика приведён к реальным ссылкам: добавлена
System.IO.Compression.FileSystem, без неё ZipFile не разрешался.

Тесты: 373 passed, ruff чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-08-24 09:21:08 +07:00
parent 05e15a4d5f
commit 8f62adb760
5 changed files with 66 additions and 2 deletions

View file

@ -3,6 +3,8 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Reflection;
using System.IO.Compression;
using System.Text;
using System.Threading;
using System.Windows.Forms;
@ -1019,6 +1021,32 @@ namespace HermesHubSetup
static class Program
{
private static string ExtractEmbeddedPayload()
{
try
{
var asm = Assembly.GetExecutingAssembly();
using (Stream res = asm.GetManifestResourceStream("payload"))
{
if (res == null) return null;
string target = Path.Combine(Path.GetTempPath(),
"HermesHubSetup_" + Guid.NewGuid().ToString("N").Substring(0, 8));
Directory.CreateDirectory(target);
string tmpZip = Path.Combine(target, "_payload.zip");
using (var fs = File.Create(tmpZip)) res.CopyTo(fs);
ZipFile.ExtractToDirectory(tmpZip, target);
File.Delete(tmpZip);
return Directory.Exists(Path.Combine(target, "src")) ? target : null;
}
}
catch (Exception ex)
{
Console.Error.WriteLine("Не удалось распаковать встроенные файлы: " + ex.Message);
return null;
}
}
[STAThread]
static int Main(string[] args)
{
@ -1052,6 +1080,15 @@ namespace HermesHubSetup
{
sourceRoot = Path.GetFullPath(Path.Combine(appDir, ".."));
}
// Ни рядом с exe, ни уровнем выше исходников нет — значит установщик
// запущен как самостоятельный файл. Содержимое вшито в него ресурсом
// и распаковывается во временный каталог. Так владельцу достаточно
// одного exe, без копирования репозитория на целевую машину.
if (!Directory.Exists(Path.Combine(sourceRoot, "src")))
{
string extracted = ExtractEmbeddedPayload();
if (extracted != null) sourceRoot = extracted;
}
// Uninstall Mode
if (isUninstall)

View file

@ -26,8 +26,29 @@ if (Test-Path $HubWebCs) {
& $CscPath /target:winexe /out:"$HubWebExe" /r:System.Windows.Forms.dll /r:System.Drawing.dll "$HubWebCs"
}
# Собираем полезную нагрузку: всё, что нужно PerformInstall на целевой машине.
# Она вшивается в exe ресурсом, чтобы установщик был одним файлом и не требовал
# копировать репозиторий.
Write-Host "Packing payload..." -ForegroundColor Cyan
$PayloadDir = Join-Path $env:TEMP ("hubpayload_" + [guid]::NewGuid().ToString("N").Substring(0,8))
New-Item -ItemType Directory -Force $PayloadDir | Out-Null
foreach ($item in @("src", "launcher", "assets", "config", "scripts")) {
$srcPath = Join-Path $RepoRoot $item
if (Test-Path $srcPath) {
Copy-Item $srcPath -Destination (Join-Path $PayloadDir $item) -Recurse -Force
}
}
# Каталоги сборки и кеши в дистрибутив не нужны.
Get-ChildItem $PayloadDir -Recurse -Directory -Include "__pycache__", ".venv", "node_modules" -ErrorAction SilentlyContinue |
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
$PayloadZip = Join-Path $env:TEMP "hub_payload.zip"
if (Test-Path $PayloadZip) { Remove-Item $PayloadZip -Force }
Compress-Archive -Path (Join-Path $PayloadDir "*") -DestinationPath $PayloadZip -CompressionLevel Optimal
$payloadKB = [int]((Get-Item $PayloadZip).Length / 1KB)
Write-Host "Payload packed: $payloadKB KB" -ForegroundColor Gray
Write-Host "Compiling HermesHubSetup.exe..." -ForegroundColor Cyan
& $CscPath /target:winexe /out:"$OutFile" /r:System.Windows.Forms.dll /r:System.Drawing.dll "$SourceFile"
& $CscPath /target:winexe /out:"$OutFile" /r:System.Windows.Forms.dll /r:System.Drawing.dll /r:System.IO.Compression.FileSystem.dll /resource:"$PayloadZip",payload "$SourceFile"
if ($LASTEXITCODE -eq 0) {
Write-Host "Installer compiled successfully: $OutFile" -ForegroundColor Green
@ -42,3 +63,6 @@ if ($LASTEXITCODE -eq 0) {
} else {
Write-Error "Installer compilation FAILED with exit code $LASTEXITCODE"
}
Remove-Item $PayloadDir -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item $PayloadZip -Force -ErrorAction SilentlyContinue

Binary file not shown.

Binary file not shown.

View file

@ -47,7 +47,10 @@ def test_windows_csharp_launchers_and_setup_compile():
setup_cs = INSTALLER_DIR / "HermesHubSetup.cs"
res3 = subprocess.run([
csc_path, "/target:winexe", f"/out:{temp_out / 'HermesHubSetup.exe'}",
"/r:System.Windows.Forms.dll", "/r:System.Drawing.dll", str(setup_cs)
# Установщик несёт содержимое вшитым ресурсом и распаковывает его через
# ZipFile — сборка требует System.IO.Compression.FileSystem.
"/r:System.Windows.Forms.dll", "/r:System.Drawing.dll",
"/r:System.IO.Compression.FileSystem.dll", str(setup_cs)
], capture_output=True, text=True)
assert res3.returncode == 0, f"HermesHubSetup.cs compilation failed: {res3.stdout}\n{res3.stderr}"