From 5bbcfb1e76b15954d8bb250504de8972680526d0 Mon Sep 17 00:00:00 2001 From: FrigaT Date: Tue, 25 Nov 2025 07:39:25 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D1=8C=D1=82?= =?UTF-8?q?=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B0.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.MD | 116 ++++++++++++++++++++++ ReleaseUpdater.slnx | 7 ++ ReleaseUpdater/GiteaReleaseProvider.cs | 85 ++++++++++++++++ ReleaseUpdater/HttpAssetDownloader.cs | 17 ++++ ReleaseUpdater/Models/AssetInfo.cs | 17 ++++ ReleaseUpdater/Models/ReleaseInfo.cs | 22 +++++ ReleaseUpdater/ReleaseUpdater.csproj | 9 ++ ReleaseUpdater/ReleaseUpdaterFacade.cs | 132 +++++++++++++++++++++++++ ReleaseUpdater/SemVerService.cs | 43 ++++++++ Updater/Core/ConsoleLogger.cs | 8 ++ Updater/Core/IExtractor.cs | 10 ++ Updater/Core/IInstaller.cs | 12 +++ Updater/Core/ILogger.cs | 12 +++ Updater/Core/IProcessManager.cs | 8 ++ Updater/Core/Options.cs | 66 +++++++++++++ Updater/Core/ProcessManager.cs | 30 ++++++ Updater/Core/SafeFileInstaller.cs | 75 ++++++++++++++ Updater/Core/UpdaterApp.cs | 67 +++++++++++++ Updater/Core/ZipExtractor.cs | 18 ++++ Updater/Program.cs | 29 ++++++ Updater/Updater.csproj | 10 ++ 21 files changed, 793 insertions(+) create mode 100644 README.MD create mode 100644 ReleaseUpdater.slnx create mode 100644 ReleaseUpdater/GiteaReleaseProvider.cs create mode 100644 ReleaseUpdater/HttpAssetDownloader.cs create mode 100644 ReleaseUpdater/Models/AssetInfo.cs create mode 100644 ReleaseUpdater/Models/ReleaseInfo.cs create mode 100644 ReleaseUpdater/ReleaseUpdater.csproj create mode 100644 ReleaseUpdater/ReleaseUpdaterFacade.cs create mode 100644 ReleaseUpdater/SemVerService.cs create mode 100644 Updater/Core/ConsoleLogger.cs create mode 100644 Updater/Core/IExtractor.cs create mode 100644 Updater/Core/IInstaller.cs create mode 100644 Updater/Core/ILogger.cs create mode 100644 Updater/Core/IProcessManager.cs create mode 100644 Updater/Core/Options.cs create mode 100644 Updater/Core/ProcessManager.cs create mode 100644 Updater/Core/SafeFileInstaller.cs create mode 100644 Updater/Core/UpdaterApp.cs create mode 100644 Updater/Core/ZipExtractor.cs create mode 100644 Updater/Program.cs create mode 100644 Updater/Updater.csproj diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..2f4c8b1 --- /dev/null +++ b/README.MD @@ -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 ` — путь к архиву .zip. +- `--installPath ` — папка установки. +- `--appExe ` — исполняемый файл приложения. +- `--restartDelayMs ` — задержка перед перезапуском (по умолчанию 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`. diff --git a/ReleaseUpdater.slnx b/ReleaseUpdater.slnx new file mode 100644 index 0000000..6430c89 --- /dev/null +++ b/ReleaseUpdater.slnx @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ReleaseUpdater/GiteaReleaseProvider.cs b/ReleaseUpdater/GiteaReleaseProvider.cs new file mode 100644 index 0000000..9f5e754 --- /dev/null +++ b/ReleaseUpdater/GiteaReleaseProvider.cs @@ -0,0 +1,85 @@ +using System.Net.Http.Headers; +using System.Text.Json; + +namespace ReleaseUpdater; + +/// +/// Провайдер для получения информации о релизах из Github / Gitea API. +/// +public sealed class GiteaReleaseProvider +{ + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); + + /// + /// Получает список всех релизов из указанного API. + /// + /// URL API Gitea для релизов. + /// Токен авторизации (если требуется). + /// Список релизов. + + public async Task> 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>(stream, JsonOpts) + ?? new List(); + + return releases.Select(Map).ToList(); + } + + /// + /// Находит релиз по версии или возвращает последний релиз. + /// + /// URL API Gitea для релизов. + /// Версия (например, "3.5.2") или "latest". + /// Токен авторизации (если требуется). + /// Информация о релизе или null. + + public async Task 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() + }; + + // DTOs для десериализации JSON + private sealed class GiteaReleaseDto + { + public string tag_name { get; set; } = ""; + public string? name { get; set; } + public List? assets { get; set; } + } + + private sealed class GiteaAssetDto + { + public string name { get; set; } = ""; + public string browser_download_url { get; set; } = ""; + } +} \ No newline at end of file diff --git a/ReleaseUpdater/HttpAssetDownloader.cs b/ReleaseUpdater/HttpAssetDownloader.cs new file mode 100644 index 0000000..727e22c --- /dev/null +++ b/ReleaseUpdater/HttpAssetDownloader.cs @@ -0,0 +1,17 @@ +namespace ReleaseUpdater; + +public sealed class HttpAssetDownloader +{ + public async Task 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; + } +} \ No newline at end of file diff --git a/ReleaseUpdater/Models/AssetInfo.cs b/ReleaseUpdater/Models/AssetInfo.cs new file mode 100644 index 0000000..f9c64e2 --- /dev/null +++ b/ReleaseUpdater/Models/AssetInfo.cs @@ -0,0 +1,17 @@ +namespace ReleaseUpdater; + +/// +/// Информация о прикрепленных файлах +/// +public sealed class AssetInfo +{ + /// + /// Название файла + /// + public required string Name { get; init; } + + /// + /// Ссылка для скачивания + /// + public required Uri DownloadUrl { get; init; } +} diff --git a/ReleaseUpdater/Models/ReleaseInfo.cs b/ReleaseUpdater/Models/ReleaseInfo.cs new file mode 100644 index 0000000..e2d8318 --- /dev/null +++ b/ReleaseUpdater/Models/ReleaseInfo.cs @@ -0,0 +1,22 @@ +namespace ReleaseUpdater; + +/// +/// Информиция о резиле +/// +public sealed class ReleaseInfo +{ + /// + /// Тэг релиза + /// + public required string TagName { get; init; } + + /// + /// Заголовок релиза + /// + public string? Name { get; init; } + + /// + /// Прикрепленные файлы + /// + public IReadOnlyList Assets { get; init; } = Array.Empty(); +} diff --git a/ReleaseUpdater/ReleaseUpdater.csproj b/ReleaseUpdater/ReleaseUpdater.csproj new file mode 100644 index 0000000..fa71b7a --- /dev/null +++ b/ReleaseUpdater/ReleaseUpdater.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/ReleaseUpdater/ReleaseUpdaterFacade.cs b/ReleaseUpdater/ReleaseUpdaterFacade.cs new file mode 100644 index 0000000..9f2f2df --- /dev/null +++ b/ReleaseUpdater/ReleaseUpdaterFacade.cs @@ -0,0 +1,132 @@ +using System.Diagnostics; +using System.IO.Compression; + +namespace ReleaseUpdater; + +/// +/// Фасад для работы с релизами и обновлением. +/// Содержит события жизненного цикла. +/// +public static class ReleaseUpdaterFacade +{ + /// + /// Событие вызывается перед установкой новой версии (после скачивания архива). + /// + public static event Action? BeforeInstall; + + /// + /// Событие вызывается после успешной установки новой версии. + /// + public static event Action? AfterInstall; + + /// + /// Событие вызывается при ошибке обновления. + /// + public static event Action? UpdateFailed; + + /// + /// Получает список доступных версий из Gitea. + /// + public static async Task> GetVersionsAsync(string apiUrl, string? token = null) + { + var provider = new GiteaReleaseProvider(); + var releases = await provider.GetReleasesAsync(apiUrl, token); + return releases.Select(r => r.TagName).ToList(); + } + + /// + /// Обновление без Updater.exe: скачивание, распаковка и перезапуск прямо из DLL. + /// + 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); + } + } + + /// + /// Обновление через внешний Updater.exe. + /// + 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 + }); + } + } + +} diff --git a/ReleaseUpdater/SemVerService.cs b/ReleaseUpdater/SemVerService.cs new file mode 100644 index 0000000..50ca57a --- /dev/null +++ b/ReleaseUpdater/SemVerService.cs @@ -0,0 +1,43 @@ +namespace ReleaseUpdater; + +/// +/// Сервис для работы с семантическими версиями. +/// +public sealed class SemVerService +{ + /// + /// Пытается распарсить строку версии в объект . + /// + /// Строка версии (например, "3.5.2"). + /// Результат парсинга. + /// true, если парсинг успешен. + 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]}" + }; + } + } + + /// + /// Сравнивает две версии. + /// + /// Первая версия. + /// Вторая версия. + /// -1 если v1 < v2, 0 если равны, 1 если v1 > v2. + public int Compare(string v1, string v2) + { + TryParse(v1, out var a); + TryParse(v2, out var b); + return a.CompareTo(b); + } +} \ No newline at end of file diff --git a/Updater/Core/ConsoleLogger.cs b/Updater/Core/ConsoleLogger.cs new file mode 100644 index 0000000..a750d1e --- /dev/null +++ b/Updater/Core/ConsoleLogger.cs @@ -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); +} \ No newline at end of file diff --git a/Updater/Core/IExtractor.cs b/Updater/Core/IExtractor.cs new file mode 100644 index 0000000..6072cff --- /dev/null +++ b/Updater/Core/IExtractor.cs @@ -0,0 +1,10 @@ +namespace Updater.Core; + +/// Извлекает содержимое архива в каталог. +public interface IExtractor +{ + /// Извлекает архив в целевой каталог. + /// Путь к ZIP-файлу. + /// Целевой каталог для извлечения. + void Extract(string archivePath, string targetDir); +} diff --git a/Updater/Core/IInstaller.cs b/Updater/Core/IInstaller.cs new file mode 100644 index 0000000..f79526b --- /dev/null +++ b/Updater/Core/IInstaller.cs @@ -0,0 +1,12 @@ +namespace Updater.Core; + +/// Безопасно устанавливает извлеченные файлы в каталог установки.. +public interface IInstaller +{ + /// + /// Копирует файлы из источника для установки. Путь с резервным копированием и откатом.. + /// + /// Каталог извлеченного содержимого. + /// Целевой каталог установки. + void Install(string sourceDir, string installPath); +} diff --git a/Updater/Core/ILogger.cs b/Updater/Core/ILogger.cs new file mode 100644 index 0000000..e55bde2 --- /dev/null +++ b/Updater/Core/ILogger.cs @@ -0,0 +1,12 @@ +namespace Updater.Core; + +/// Простая абстракция журналирования. +public interface ILogger +{ + /// Информационное сообщение. + void Info(string message); + /// Предупреждающее сообщение. + void Warn(string message); + /// Сообщение об ошибке. + void Error(string message); +} diff --git a/Updater/Core/IProcessManager.cs b/Updater/Core/IProcessManager.cs new file mode 100644 index 0000000..af775ba --- /dev/null +++ b/Updater/Core/IProcessManager.cs @@ -0,0 +1,8 @@ +namespace Updater.Core; + +/// Управляет перезапуском приложения. +public interface IProcessManager +{ + /// Запускает исполняемый файл приложения из рабочего каталога. + void StartApp(string installPath, string appExe, int delayMs); +} diff --git a/Updater/Core/Options.cs b/Updater/Core/Options.cs new file mode 100644 index 0000000..1faabd3 --- /dev/null +++ b/Updater/Core/Options.cs @@ -0,0 +1,66 @@ +namespace Updater.Core; + +/// Параметры CLI для Updater.exe. +public sealed class Options +{ + /// Путь к скачанному архиву (.zip). + public required string ZipPath { get; init; } + + /// Целевой каталог установки. + public required string InstallPath { get; init; } + + /// Имя исполняемого файла приложения для перезапуска (e.g., MyBot.exe). + public required string AppExe { get; init; } + + /// Необязательно: подождите миллисекунды перед перезапуском (льготный период). + public int RestartDelayMs { get; init; } = 500; + + public static string Usage => + "Usage: Updater.exe --zip --installPath --appExe [--restartDelayMs ]"; + + /// Папрсинг CLI аргументов в Options. + public static Options Parse(string[] args) + { + var dict = new Dictionary(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 dict, string key) + => dict.TryGetValue(key, out var v) && !string.IsNullOrWhiteSpace(v) + ? v : throw new ArgumentException($"Missing --{key}"); +} + +public static class ExitCodes +{ + /// Успешное обновление. + public const int Ok = 0; + /// Неверные аргументы командной строки. + public const int InvalidArgs = 2; + /// Ошибка извлечения. + public const int ExtractFailed = 3; + /// Ошибка установки (копировать/заменить). + public const int InstallFailed = 4; + /// Ошибка перезапуска. + public const int RestartFailed = 5; +} diff --git a/Updater/Core/ProcessManager.cs b/Updater/Core/ProcessManager.cs new file mode 100644 index 0000000..10457bd --- /dev/null +++ b/Updater/Core/ProcessManager.cs @@ -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 + }); + } +} \ No newline at end of file diff --git a/Updater/Core/SafeFileInstaller.cs b/Updater/Core/SafeFileInstaller.cs new file mode 100644 index 0000000..50edf60 --- /dev/null +++ b/Updater/Core/SafeFileInstaller.cs @@ -0,0 +1,75 @@ +namespace Updater.Core; + +/// +/// Безопасный установщик: создает резервную копию замененных файлов, выполняет атомарную замену, +/// и откатываемся в случае неудачи. +/// +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 */ } + } +} \ No newline at end of file diff --git a/Updater/Core/UpdaterApp.cs b/Updater/Core/UpdaterApp.cs new file mode 100644 index 0000000..d267e59 --- /dev/null +++ b/Updater/Core/UpdaterApp.cs @@ -0,0 +1,67 @@ +namespace Updater.Core; + +/// +/// Управляет потоком обновлений: извлечение, установка, перезапуск. +/// +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; + } + + /// Запускает рабочий процесс обновления. + 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 */ } + } +} \ No newline at end of file diff --git a/Updater/Core/ZipExtractor.cs b/Updater/Core/ZipExtractor.cs new file mode 100644 index 0000000..207622d --- /dev/null +++ b/Updater/Core/ZipExtractor.cs @@ -0,0 +1,18 @@ +using System.IO.Compression; + +namespace Updater.Core; + +/// ZIP-экстрактор с использованием System.IO.Compression. +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); + } +} diff --git a/Updater/Program.cs b/Updater/Program.cs new file mode 100644 index 0000000..b5bd072 --- /dev/null +++ b/Updater/Program.cs @@ -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); + } +} diff --git a/Updater/Updater.csproj b/Updater/Updater.csproj new file mode 100644 index 0000000..2150e37 --- /dev/null +++ b/Updater/Updater.csproj @@ -0,0 +1,10 @@ + + + + Exe + net8.0 + enable + enable + + +