Добавьте файлы проекта.
This commit is contained in:
116
README.MD
Normal file
116
README.MD
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Updater
|
||||||
|
|
||||||
|
## 📖 Описание
|
||||||
|
`Updater` — это инструмент для безопасного обновления приложений.
|
||||||
|
Он состоит из двух частей:
|
||||||
|
|
||||||
|
- **ReleaseUpdater.dll** — библиотека для работы с API Gitea/GitHub:
|
||||||
|
- Получение списка доступных версий.
|
||||||
|
- Выбор нужной версии.
|
||||||
|
- Скачивание архива релиза.
|
||||||
|
- События жизненного цикла (`BeforeInstall`, `AfterInstall`, `UpdateFailed`).
|
||||||
|
- Запуск обновления либо напрямую (`UpdateInlineAsync`), либо через внешний процесс (`UpdateWithExternalAsync`).
|
||||||
|
|
||||||
|
- **Updater.exe** — консольное приложение:
|
||||||
|
- Принимает параметры (`--zip`, `--installPath`, `--appExe`).
|
||||||
|
- Распаковывает архив в папку установки.
|
||||||
|
- Перезапускает приложение.
|
||||||
|
- Работает как «чистый установщик», чтобы избежать проблем с блокировкой файлов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗 Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
Solution/
|
||||||
|
├─ ReleaseUpdater/ # Class Library (.dll)
|
||||||
|
│ ├─ ReleaseUpdaterFacade.cs
|
||||||
|
│ ├─ GiteaReleaseProvider.cs
|
||||||
|
│ ├─ HttpAssetDownloader.cs
|
||||||
|
│ ├─ SemVerService.cs
|
||||||
|
│ └─ Models.cs
|
||||||
|
│
|
||||||
|
├─ Updater/ # Console App (.exe)
|
||||||
|
│ ├─ Program.cs
|
||||||
|
│ ├─ Core/
|
||||||
|
│ │ ├─ IExtractor.cs / ZipExtractor.cs
|
||||||
|
│ │ ├─ IInstaller.cs / SafeFileInstaller.cs
|
||||||
|
│ │ ├─ IProcessManager.cs / ProcessManager.cs
|
||||||
|
│ │ ├─ UpdaterApp.cs
|
||||||
|
│ │ └─ Options.cs
|
||||||
|
│
|
||||||
|
└─ UpdaterSolution.sln
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ Возможности
|
||||||
|
|
||||||
|
- Получение списка версий из Gitea API.
|
||||||
|
- Обновление до последней или конкретной версии.
|
||||||
|
- Два режима обновления:
|
||||||
|
- **Inline** — всё внутри DLL.
|
||||||
|
- **External** — через Updater.exe.
|
||||||
|
- События:
|
||||||
|
- `BeforeInstall` — перед установкой.
|
||||||
|
- `AfterInstall` — после успешной установки.
|
||||||
|
- `UpdateFailed` — при ошибке (автоматический запуск текущей версии).
|
||||||
|
- Безопасная установка: бэкап и откат при ошибке.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Использование
|
||||||
|
|
||||||
|
### В Telegram‑боте
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Получение списка версий
|
||||||
|
var versions = await ReleaseUpdaterFacade.GetVersionsAsync(apiUrl, token);
|
||||||
|
|
||||||
|
// Обновление до последней версии
|
||||||
|
await ReleaseUpdaterFacade.UpdateInlineAsync(apiUrl, token, installPath, "MyBot.exe", "latest");
|
||||||
|
|
||||||
|
// Обновление через внешний Updater.exe
|
||||||
|
await ReleaseUpdaterFacade.UpdateWithExternalAsync(apiUrl, token, installPath, "MyBot.exe", "3.5.2");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск Updater.exe напрямую
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Updater.exe --zip "C:\Temp\update.zip" --installPath "C:\Apps\MyBot" --appExe "MyBot.exe"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Параметры Updater.exe
|
||||||
|
|
||||||
|
- `--zip <path>` — путь к архиву .zip.
|
||||||
|
- `--installPath <dir>` — папка установки.
|
||||||
|
- `--appExe <file.exe>` — исполняемый файл приложения.
|
||||||
|
- `--restartDelayMs <int>` — задержка перед перезапуском (по умолчанию 500 мс).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Коды возврата
|
||||||
|
|
||||||
|
- `0` — успешное обновление.
|
||||||
|
- `2` — ошибка аргументов.
|
||||||
|
- `3` — ошибка распаковки.
|
||||||
|
- `4` — ошибка установки.
|
||||||
|
- `5` — ошибка перезапуска.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠 Пример сценария
|
||||||
|
|
||||||
|
1. Бот получает команду `/update latest`.
|
||||||
|
2. ReleaseUpdater.dll:
|
||||||
|
- Запрашивает список релизов через Gitea API.
|
||||||
|
- Скачивает архив последней версии.
|
||||||
|
- Вызывает событие `BeforeInstall`.
|
||||||
|
- Запускает Updater.exe с параметрами.
|
||||||
|
3. Updater.exe:
|
||||||
|
- Распаковывает архив.
|
||||||
|
- Перезапускает приложение.
|
||||||
|
- Возвращает код `0`.
|
||||||
|
4. ReleaseUpdater.dll вызывает событие `AfterInstall`.
|
||||||
7
ReleaseUpdater.slnx
Normal file
7
ReleaseUpdater.slnx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/Элементы решения/">
|
||||||
|
<File Path="README.MD" />
|
||||||
|
</Folder>
|
||||||
|
<Project Path="ReleaseUpdater/ReleaseUpdater.csproj" />
|
||||||
|
<Project Path="Updater/Updater.csproj" />
|
||||||
|
</Solution>
|
||||||
85
ReleaseUpdater/GiteaReleaseProvider.cs
Normal file
85
ReleaseUpdater/GiteaReleaseProvider.cs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ReleaseUpdater;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Провайдер для получения информации о релизах из Github / Gitea API.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GiteaReleaseProvider
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Получает список всех релизов из указанного API.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="apiUrl">URL API Gitea для релизов.</param>
|
||||||
|
/// <param name="token">Токен авторизации (если требуется).</param>
|
||||||
|
/// <returns>Список релизов.</returns>
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ReleaseInfo>> GetReleasesAsync(string apiUrl, string? token = null)
|
||||||
|
{
|
||||||
|
using var client = CreateClient(token);
|
||||||
|
using var resp = await client.GetAsync(apiUrl);
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var stream = await resp.Content.ReadAsStreamAsync();
|
||||||
|
var releases = await JsonSerializer.DeserializeAsync<List<GiteaReleaseDto>>(stream, JsonOpts)
|
||||||
|
?? new List<GiteaReleaseDto>();
|
||||||
|
|
||||||
|
return releases.Select(Map).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Находит релиз по версии или возвращает последний релиз.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="apiUrl">URL API Gitea для релизов.</param>
|
||||||
|
/// <param name="versionOrLatest">Версия (например, "3.5.2") или "latest".</param>
|
||||||
|
/// <param name="token">Токен авторизации (если требуется).</param>
|
||||||
|
/// <returns>Информация о релизе или null.</returns>
|
||||||
|
|
||||||
|
public async Task<ReleaseInfo?> FindReleaseAsync(string apiUrl, string? versionOrLatest, string? token = null)
|
||||||
|
{
|
||||||
|
var all = await GetReleasesAsync(apiUrl, token);
|
||||||
|
if (string.IsNullOrWhiteSpace(versionOrLatest) || versionOrLatest.Equals("latest", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return all.FirstOrDefault();
|
||||||
|
|
||||||
|
var tag = versionOrLatest.StartsWith('v') ? versionOrLatest : $"v{versionOrLatest}";
|
||||||
|
return all.FirstOrDefault(r => string.Equals(r.TagName, tag, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpClient CreateClient(string? token)
|
||||||
|
{
|
||||||
|
var client = new HttpClient();
|
||||||
|
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("ReleaseUpdater", "1.0"));
|
||||||
|
if (!string.IsNullOrWhiteSpace(token))
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", token);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReleaseInfo Map(GiteaReleaseDto dto) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
TagName = dto.tag_name,
|
||||||
|
Name = dto.name,
|
||||||
|
Assets = dto.assets?.Select(a => new AssetInfo
|
||||||
|
{
|
||||||
|
Name = a.name,
|
||||||
|
DownloadUrl = new Uri(a.browser_download_url)
|
||||||
|
}).ToList() ?? new List<AssetInfo>()
|
||||||
|
};
|
||||||
|
|
||||||
|
// DTOs для десериализации JSON
|
||||||
|
private sealed class GiteaReleaseDto
|
||||||
|
{
|
||||||
|
public string tag_name { get; set; } = "";
|
||||||
|
public string? name { get; set; }
|
||||||
|
public List<GiteaAssetDto>? assets { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class GiteaAssetDto
|
||||||
|
{
|
||||||
|
public string name { get; set; } = "";
|
||||||
|
public string browser_download_url { get; set; } = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
17
ReleaseUpdater/HttpAssetDownloader.cs
Normal file
17
ReleaseUpdater/HttpAssetDownloader.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
namespace ReleaseUpdater;
|
||||||
|
|
||||||
|
public sealed class HttpAssetDownloader
|
||||||
|
{
|
||||||
|
public async Task<string> DownloadAssetAsync(Uri downloadUrl, string? token, string? downloadToFilePath = null)
|
||||||
|
{
|
||||||
|
var target = downloadToFilePath ?? Path.Combine(Path.GetTempPath(), $"updater_{Guid.NewGuid():N}.zip");
|
||||||
|
using var client = new HttpClient();
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("ReleaseUpdater/1.0");
|
||||||
|
if (!string.IsNullOrWhiteSpace(token))
|
||||||
|
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("token", token);
|
||||||
|
|
||||||
|
var bytes = await client.GetByteArrayAsync(downloadUrl);
|
||||||
|
await File.WriteAllBytesAsync(target, bytes);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
ReleaseUpdater/Models/AssetInfo.cs
Normal file
17
ReleaseUpdater/Models/AssetInfo.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
namespace ReleaseUpdater;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Информация о прикрепленных файлах
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AssetInfo
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Название файла
|
||||||
|
/// </summary>
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ссылка для скачивания
|
||||||
|
/// </summary>
|
||||||
|
public required Uri DownloadUrl { get; init; }
|
||||||
|
}
|
||||||
22
ReleaseUpdater/Models/ReleaseInfo.cs
Normal file
22
ReleaseUpdater/Models/ReleaseInfo.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
namespace ReleaseUpdater;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Информиция о резиле
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReleaseInfo
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Тэг релиза
|
||||||
|
/// </summary>
|
||||||
|
public required string TagName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Заголовок релиза
|
||||||
|
/// </summary>
|
||||||
|
public string? Name { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Прикрепленные файлы
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<AssetInfo> Assets { get; init; } = Array.Empty<AssetInfo>();
|
||||||
|
}
|
||||||
9
ReleaseUpdater/ReleaseUpdater.csproj
Normal file
9
ReleaseUpdater/ReleaseUpdater.csproj
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
132
ReleaseUpdater/ReleaseUpdaterFacade.cs
Normal file
132
ReleaseUpdater/ReleaseUpdaterFacade.cs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO.Compression;
|
||||||
|
|
||||||
|
namespace ReleaseUpdater;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Фасад для работы с релизами и обновлением.
|
||||||
|
/// Содержит события жизненного цикла.
|
||||||
|
/// </summary>
|
||||||
|
public static class ReleaseUpdaterFacade
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Событие вызывается перед установкой новой версии (после скачивания архива).
|
||||||
|
/// </summary>
|
||||||
|
public static event Action? BeforeInstall;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Событие вызывается после успешной установки новой версии.
|
||||||
|
/// </summary>
|
||||||
|
public static event Action? AfterInstall;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Событие вызывается при ошибке обновления.
|
||||||
|
/// </summary>
|
||||||
|
public static event Action<Exception>? UpdateFailed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Получает список доступных версий из Gitea.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<IReadOnlyList<string>> GetVersionsAsync(string apiUrl, string? token = null)
|
||||||
|
{
|
||||||
|
var provider = new GiteaReleaseProvider();
|
||||||
|
var releases = await provider.GetReleasesAsync(apiUrl, token);
|
||||||
|
return releases.Select(r => r.TagName).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Обновление без Updater.exe: скачивание, распаковка и перезапуск прямо из DLL.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task UpdateInlineAsync(
|
||||||
|
string apiUrl, string? token, string installPath, string appExe, string versionOrLatest = "latest")
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var provider = new GiteaReleaseProvider();
|
||||||
|
var release = await provider.FindReleaseAsync(apiUrl, versionOrLatest, token)
|
||||||
|
?? throw new Exception("Release not found");
|
||||||
|
|
||||||
|
var asset = release.Assets.FirstOrDefault(a => a.Name.EndsWith(".zip"))
|
||||||
|
?? throw new Exception("No zip asset found");
|
||||||
|
|
||||||
|
var downloader = new HttpAssetDownloader();
|
||||||
|
var zipPath = await downloader.DownloadAssetAsync(asset.DownloadUrl, token);
|
||||||
|
|
||||||
|
BeforeInstall?.Invoke();
|
||||||
|
|
||||||
|
ZipFile.ExtractToDirectory(zipPath, installPath, true);
|
||||||
|
|
||||||
|
AfterInstall?.Invoke();
|
||||||
|
|
||||||
|
Process.Start(Path.Combine(installPath, appExe));
|
||||||
|
Environment.Exit(0);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
UpdateFailed?.Invoke(ex);
|
||||||
|
RestartCurrent(installPath, appExe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Обновление через внешний Updater.exe.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task UpdateWithExternalAsync(
|
||||||
|
string apiUrl, string? token, string installPath, string appExe, string versionOrLatest = "latest")
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var provider = new GiteaReleaseProvider();
|
||||||
|
var release = await provider.FindReleaseAsync(apiUrl, versionOrLatest, token)
|
||||||
|
?? throw new Exception("Release not found");
|
||||||
|
|
||||||
|
var asset = release.Assets.FirstOrDefault(a => a.Name.EndsWith(".zip"))
|
||||||
|
?? throw new Exception("No zip asset found");
|
||||||
|
|
||||||
|
var downloader = new HttpAssetDownloader();
|
||||||
|
var zipPath = await downloader.DownloadAssetAsync(asset.DownloadUrl, token);
|
||||||
|
|
||||||
|
BeforeInstall?.Invoke();
|
||||||
|
|
||||||
|
var updaterExe = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Updater.exe");
|
||||||
|
var args = $"--zip \"{zipPath}\" --installPath \"{installPath}\" --appExe \"{appExe}\"";
|
||||||
|
|
||||||
|
var process = Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = updaterExe,
|
||||||
|
Arguments = args,
|
||||||
|
UseShellExecute = true,
|
||||||
|
WorkingDirectory = AppDomain.CurrentDomain.BaseDirectory
|
||||||
|
});
|
||||||
|
|
||||||
|
process?.WaitForExit();
|
||||||
|
|
||||||
|
if (process?.ExitCode == 0)
|
||||||
|
AfterInstall?.Invoke();
|
||||||
|
else
|
||||||
|
throw new Exception($"Updater.exe завершился с кодом {process?.ExitCode}");
|
||||||
|
|
||||||
|
Environment.Exit(0);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
UpdateFailed?.Invoke(ex);
|
||||||
|
RestartCurrent(installPath, appExe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RestartCurrent(string installPath, string appExe)
|
||||||
|
{
|
||||||
|
var currentApp = Path.Combine(installPath, appExe);
|
||||||
|
if (File.Exists(currentApp))
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = currentApp,
|
||||||
|
UseShellExecute = true,
|
||||||
|
WorkingDirectory = installPath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
43
ReleaseUpdater/SemVerService.cs
Normal file
43
ReleaseUpdater/SemVerService.cs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
namespace ReleaseUpdater;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Сервис для работы с семантическими версиями.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SemVerService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Пытается распарсить строку версии в объект <see cref="Version"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="version">Строка версии (например, "3.5.2").</param>
|
||||||
|
/// <param name="parsed">Результат парсинга.</param>
|
||||||
|
/// <returns>true, если парсинг успешен.</returns>
|
||||||
|
public bool TryParse(string version, out Version parsed)
|
||||||
|
{
|
||||||
|
var v = version.Trim().TrimStart('v');
|
||||||
|
return Version.TryParse(Normalize(v), out parsed);
|
||||||
|
|
||||||
|
static string Normalize(string v)
|
||||||
|
{
|
||||||
|
var parts = v.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
return parts.Length switch
|
||||||
|
{
|
||||||
|
1 => $"{parts[0]}.0.0",
|
||||||
|
2 => $"{parts[0]}.{parts[1]}.0",
|
||||||
|
_ => $"{parts[0]}.{parts[1]}.{parts[2]}"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Сравнивает две версии.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="v1">Первая версия.</param>
|
||||||
|
/// <param name="v2">Вторая версия.</param>
|
||||||
|
/// <returns>-1 если v1 < v2, 0 если равны, 1 если v1 > v2.</returns>
|
||||||
|
public int Compare(string v1, string v2)
|
||||||
|
{
|
||||||
|
TryParse(v1, out var a);
|
||||||
|
TryParse(v2, out var b);
|
||||||
|
return a.CompareTo(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
Updater/Core/ConsoleLogger.cs
Normal file
8
Updater/Core/ConsoleLogger.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Updater.Core;
|
||||||
|
|
||||||
|
public sealed class ConsoleLogger : ILogger
|
||||||
|
{
|
||||||
|
public void Info(string message) => Console.WriteLine(message);
|
||||||
|
public void Warn(string message) => Console.WriteLine("[WARN] " + message);
|
||||||
|
public void Error(string message) => Console.Error.WriteLine("[ERR] " + message);
|
||||||
|
}
|
||||||
10
Updater/Core/IExtractor.cs
Normal file
10
Updater/Core/IExtractor.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Updater.Core;
|
||||||
|
|
||||||
|
/// <summary>Извлекает содержимое архива в каталог.</summary>
|
||||||
|
public interface IExtractor
|
||||||
|
{
|
||||||
|
/// <summary>Извлекает архив в целевой каталог.</summary>
|
||||||
|
/// <param name="archivePath">Путь к ZIP-файлу.</param>
|
||||||
|
/// <param name="targetDir">Целевой каталог для извлечения.</param>
|
||||||
|
void Extract(string archivePath, string targetDir);
|
||||||
|
}
|
||||||
12
Updater/Core/IInstaller.cs
Normal file
12
Updater/Core/IInstaller.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Updater.Core;
|
||||||
|
|
||||||
|
/// <summary>Безопасно устанавливает извлеченные файлы в каталог установки..</summary>
|
||||||
|
public interface IInstaller
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Копирует файлы из источника для установки. Путь с резервным копированием и откатом..
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sourceDir">Каталог извлеченного содержимого.</param>
|
||||||
|
/// <param name="installPath">Целевой каталог установки.</param>
|
||||||
|
void Install(string sourceDir, string installPath);
|
||||||
|
}
|
||||||
12
Updater/Core/ILogger.cs
Normal file
12
Updater/Core/ILogger.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Updater.Core;
|
||||||
|
|
||||||
|
/// <summary>Простая абстракция журналирования.</summary>
|
||||||
|
public interface ILogger
|
||||||
|
{
|
||||||
|
/// <summary>Информационное сообщение.</summary>
|
||||||
|
void Info(string message);
|
||||||
|
/// <summary>Предупреждающее сообщение.</summary>
|
||||||
|
void Warn(string message);
|
||||||
|
/// <summary>Сообщение об ошибке.</summary>
|
||||||
|
void Error(string message);
|
||||||
|
}
|
||||||
8
Updater/Core/IProcessManager.cs
Normal file
8
Updater/Core/IProcessManager.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Updater.Core;
|
||||||
|
|
||||||
|
/// <summary>Управляет перезапуском приложения.</summary>
|
||||||
|
public interface IProcessManager
|
||||||
|
{
|
||||||
|
/// <summary>Запускает исполняемый файл приложения из рабочего каталога.</summary>
|
||||||
|
void StartApp(string installPath, string appExe, int delayMs);
|
||||||
|
}
|
||||||
66
Updater/Core/Options.cs
Normal file
66
Updater/Core/Options.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
namespace Updater.Core;
|
||||||
|
|
||||||
|
/// <summary>Параметры CLI для Updater.exe.</summary>
|
||||||
|
public sealed class Options
|
||||||
|
{
|
||||||
|
/// <summary>Путь к скачанному архиву (.zip).</summary>
|
||||||
|
public required string ZipPath { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Целевой каталог установки.</summary>
|
||||||
|
public required string InstallPath { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Имя исполняемого файла приложения для перезапуска (e.g., MyBot.exe).</summary>
|
||||||
|
public required string AppExe { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Необязательно: подождите миллисекунды перед перезапуском (льготный период).</summary>
|
||||||
|
public int RestartDelayMs { get; init; } = 500;
|
||||||
|
|
||||||
|
public static string Usage =>
|
||||||
|
"Usage: Updater.exe --zip <path.zip> --installPath <dir> --appExe <file.exe> [--restartDelayMs <int>]";
|
||||||
|
|
||||||
|
/// <summary>Папрсинг CLI аргументов в Options.</summary>
|
||||||
|
public static Options Parse(string[] args)
|
||||||
|
{
|
||||||
|
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
for (int i = 0; i < args.Length; i++)
|
||||||
|
{
|
||||||
|
if (!args[i].StartsWith("--")) continue;
|
||||||
|
var key = args[i][2..];
|
||||||
|
var val = (i + 1 < args.Length && !args[i + 1].StartsWith("--")) ? args[i + 1] : "true";
|
||||||
|
dict[key] = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
var zip = Require(dict, "zip");
|
||||||
|
var install = Require(dict, "installPath");
|
||||||
|
var exe = Require(dict, "appExe");
|
||||||
|
|
||||||
|
if (!File.Exists(zip)) throw new FileNotFoundException("Zip not found", zip);
|
||||||
|
Directory.CreateDirectory(install);
|
||||||
|
|
||||||
|
return new Options
|
||||||
|
{
|
||||||
|
ZipPath = Path.GetFullPath(zip),
|
||||||
|
InstallPath = Path.GetFullPath(install),
|
||||||
|
AppExe = exe,
|
||||||
|
RestartDelayMs = dict.TryGetValue("restartDelayMs", out var d) && int.TryParse(d, out var n) ? n : 500
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Require(IDictionary<string, string> dict, string key)
|
||||||
|
=> dict.TryGetValue(key, out var v) && !string.IsNullOrWhiteSpace(v)
|
||||||
|
? v : throw new ArgumentException($"Missing --{key}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ExitCodes
|
||||||
|
{
|
||||||
|
/// <summary>Успешное обновление.</summary>
|
||||||
|
public const int Ok = 0;
|
||||||
|
/// <summary>Неверные аргументы командной строки.</summary>
|
||||||
|
public const int InvalidArgs = 2;
|
||||||
|
/// <summary>Ошибка извлечения.</summary>
|
||||||
|
public const int ExtractFailed = 3;
|
||||||
|
/// <summary>Ошибка установки (копировать/заменить).</summary>
|
||||||
|
public const int InstallFailed = 4;
|
||||||
|
/// <summary>Ошибка перезапуска.</summary>
|
||||||
|
public const int RestartFailed = 5;
|
||||||
|
}
|
||||||
30
Updater/Core/ProcessManager.cs
Normal file
30
Updater/Core/ProcessManager.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace Updater.Core;
|
||||||
|
|
||||||
|
public sealed class ProcessManager : IProcessManager
|
||||||
|
{
|
||||||
|
private readonly ILogger _log;
|
||||||
|
public ProcessManager(ILogger log) => _log = log;
|
||||||
|
|
||||||
|
public void StartApp(string installPath, string appExe, int delayMs)
|
||||||
|
{
|
||||||
|
var appPath = Path.Combine(installPath, appExe);
|
||||||
|
if (!File.Exists(appPath))
|
||||||
|
throw new FileNotFoundException("Executable not found after install", appPath);
|
||||||
|
|
||||||
|
if (delayMs > 0)
|
||||||
|
{
|
||||||
|
_log.Info($"Waiting {delayMs} ms before restart...");
|
||||||
|
Thread.Sleep(delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.Info($"Starting '{appExe}'...");
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = appPath,
|
||||||
|
UseShellExecute = true,
|
||||||
|
WorkingDirectory = installPath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
75
Updater/Core/SafeFileInstaller.cs
Normal file
75
Updater/Core/SafeFileInstaller.cs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
namespace Updater.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Безопасный установщик: создает резервную копию замененных файлов, выполняет атомарную замену,
|
||||||
|
/// и откатываемся в случае неудачи.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SafeFileInstaller : IInstaller
|
||||||
|
{
|
||||||
|
private readonly ILogger _log;
|
||||||
|
public SafeFileInstaller(ILogger log) => _log = log;
|
||||||
|
|
||||||
|
public void Install(string sourceDir, string installPath)
|
||||||
|
{
|
||||||
|
var backupDir = Path.Combine(Path.GetTempPath(), $"upd_backup_{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(backupDir);
|
||||||
|
var completed = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Копирование файлов с резервной копией целей
|
||||||
|
foreach (var src in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
var rel = Path.GetRelativePath(sourceDir, src);
|
||||||
|
var dst = Path.Combine(installPath, rel);
|
||||||
|
var dstDir = Path.GetDirectoryName(dst)!;
|
||||||
|
Directory.CreateDirectory(dstDir);
|
||||||
|
|
||||||
|
if (File.Exists(dst))
|
||||||
|
{
|
||||||
|
var bkp = Path.Combine(backupDir, rel);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(bkp)!);
|
||||||
|
File.Copy(dst, bkp, overwrite: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Copy(src, dst, overwrite: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
completed = true;
|
||||||
|
_log.Info("Install completed successfully.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Error($"Install failed: {ex.Message}");
|
||||||
|
_log.Warn("Rolling back...");
|
||||||
|
Rollback(backupDir, installPath);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Очистка резервной копии только в случае успеха
|
||||||
|
if (completed)
|
||||||
|
{
|
||||||
|
TryDeleteDirectory(backupDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Rollback(string backupDir, string installPath)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(backupDir)) return;
|
||||||
|
|
||||||
|
foreach (var bkp in Directory.EnumerateFiles(backupDir, "*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
var rel = Path.GetRelativePath(backupDir, bkp);
|
||||||
|
var dst = Path.Combine(installPath, rel);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(dst)!);
|
||||||
|
File.Copy(bkp, dst, overwrite: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryDeleteDirectory(string dir)
|
||||||
|
{
|
||||||
|
try { Directory.Delete(dir, true); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
67
Updater/Core/UpdaterApp.cs
Normal file
67
Updater/Core/UpdaterApp.cs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
namespace Updater.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Управляет потоком обновлений: извлечение, установка, перезапуск.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UpdaterApp
|
||||||
|
{
|
||||||
|
private readonly ILogger _log;
|
||||||
|
private readonly IExtractor _extractor;
|
||||||
|
private readonly IInstaller _installer;
|
||||||
|
private readonly IProcessManager _proc;
|
||||||
|
|
||||||
|
public UpdaterApp(ILogger log, IExtractor extractor, IInstaller installer, IProcessManager proc)
|
||||||
|
{
|
||||||
|
_log = log;
|
||||||
|
_extractor = extractor;
|
||||||
|
_installer = installer;
|
||||||
|
_proc = proc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Запускает рабочий процесс обновления.</summary>
|
||||||
|
public int Run(Options opts)
|
||||||
|
{
|
||||||
|
var tempExtractDir = Path.Combine(Path.GetTempPath(), $"upd_extract_{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(tempExtractDir);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_extractor.Extract(opts.ZipPath, tempExtractDir);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Error($"Extraction failed: {ex.Message}");
|
||||||
|
Cleanup(tempExtractDir);
|
||||||
|
return ExitCodes.ExtractFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_installer.Install(tempExtractDir, opts.InstallPath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Error($"Install failed: {ex.Message}");
|
||||||
|
Cleanup(tempExtractDir);
|
||||||
|
return ExitCodes.InstallFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
Cleanup(tempExtractDir);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_proc.StartApp(opts.InstallPath, opts.AppExe, opts.RestartDelayMs);
|
||||||
|
return ExitCodes.Ok;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Error($"Restart failed: {ex.Message}");
|
||||||
|
return ExitCodes.RestartFailed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Cleanup(string dir)
|
||||||
|
{
|
||||||
|
try { Directory.Delete(dir, true); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
18
Updater/Core/ZipExtractor.cs
Normal file
18
Updater/Core/ZipExtractor.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
|
||||||
|
namespace Updater.Core;
|
||||||
|
|
||||||
|
/// <summary>ZIP-экстрактор с использованием System.IO.Compression.</summary>
|
||||||
|
public sealed class ZipExtractor : IExtractor
|
||||||
|
{
|
||||||
|
private readonly ILogger _log;
|
||||||
|
public ZipExtractor(ILogger log) => _log = log;
|
||||||
|
|
||||||
|
public void Extract(string archivePath, string targetDir)
|
||||||
|
{
|
||||||
|
_log.Info($"Extracting '{archivePath}' to '{targetDir}'...");
|
||||||
|
if (Directory.Exists(targetDir)) Directory.Delete(targetDir, true);
|
||||||
|
Directory.CreateDirectory(targetDir);
|
||||||
|
ZipFile.ExtractToDirectory(archivePath, targetDir, overwriteFiles: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
Updater/Program.cs
Normal file
29
Updater/Program.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using Updater.Core;
|
||||||
|
|
||||||
|
namespace Updater;
|
||||||
|
|
||||||
|
internal sealed class Program
|
||||||
|
{
|
||||||
|
static int Main(string[] args)
|
||||||
|
{
|
||||||
|
var logger = new ConsoleLogger();
|
||||||
|
Options? options;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
options = Options.Parse(args);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.Error($"Arguments error: {ex.Message}");
|
||||||
|
Console.WriteLine(Options.Usage);
|
||||||
|
return ExitCodes.InvalidArgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
var extractor = new ZipExtractor(logger);
|
||||||
|
var installer = new SafeFileInstaller(logger);
|
||||||
|
var procMgr = new ProcessManager(logger);
|
||||||
|
var app = new UpdaterApp(logger, extractor, installer, procMgr);
|
||||||
|
|
||||||
|
return app.Run(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
Updater/Updater.csproj
Normal file
10
Updater/Updater.csproj
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user