namespace Sonex.Services.WebApi.EndPoints; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Sonex.Services.WebApi.Helpers; using System; using System.IO; using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; public static class SonexAppEndpoints { public record SonexClientVersion(string FileName, string Url, string Version, long Size, string Sha256); private static readonly object _lock = new(); public static SonexClientVersion? _latest; private static FileSystemWatcher? _watcher; private static CancellationTokenSource? _debounceCts; public static void MapSonexEndpoints(this WebApplication app, string releasesPath) { if (!Directory.Exists(releasesPath)) { app.Logger.LogWarning("Releases path does not exist: {Path}", releasesPath); } else { RefreshLatest(app.Logger, releasesPath); StartWatcher(app, releasesPath); } app.MapGet("/latest.json", () => { lock (_lock) { return _latest is null ? Results.NotFound() : Results.Json(_latest); } }); } private static void StartWatcher(WebApplication app, string releasesPath) { _watcher = new FileSystemWatcher(releasesPath) { Filter = "*.zip", IncludeSubdirectories = false, NotifyFilter = NotifyFilters.FileName | NotifyFilters.Size | NotifyFilters.LastWrite | NotifyFilters.CreationTime, EnableRaisingEvents = true }; _watcher.Created += (_, _) => DebounceRefresh(app.Logger, releasesPath); _watcher.Changed += (_, _) => DebounceRefresh(app.Logger, releasesPath); _watcher.Deleted += (_, _) => DebounceRefresh(app.Logger, releasesPath); _watcher.Renamed += (_, _) => DebounceRefresh(app.Logger, releasesPath); app.Lifetime.ApplicationStopping.Register(() => { try { _watcher?.Dispose(); } catch { } try { _debounceCts?.Cancel(); } catch { } try { _debounceCts?.Dispose(); } catch { } }); } private static void DebounceRefresh(ILogger logger, string releasesPath) { CancellationTokenSource cts; lock (_lock) { _debounceCts?.Cancel(); _debounceCts?.Dispose(); _debounceCts = new CancellationTokenSource(); cts = _debounceCts; } _ = Task.Run(async () => { try { await Task.Delay(1200, cts.Token); RefreshLatest(logger, releasesPath); } catch (OperationCanceledException) { } }); } private static void RefreshLatest(ILogger logger, string releasesPath) { SonexClientVersion? latest = FindLatestRelease(logger, releasesPath); lock (_lock) { _latest = latest; } logger.LogInformation("Latest release: {Version}", latest?.Version ?? "none"); } private static SonexClientVersion? FindLatestRelease(ILogger logger, string releasesPath) { string[] files; try { files = Directory.GetFiles(releasesPath, "*.zip"); } catch (Exception ex) { logger.LogError(ex, "Cannot read releases directory."); return null; } string? bestPath = null; string? bestVersion = null; foreach (string path in files) { string fileName = Path.GetFileName(path); if (!TryExtractVersion(fileName, out string version)) continue; if (bestVersion is null || string.Compare(version, bestVersion, StringComparison.Ordinal) > 0) { bestVersion = version; bestPath = path; } } if (bestPath is null || bestVersion is null) return null; if (!WaitUntilFileStable(bestPath, attempts: 180, delayMs: 1000, stableChecks: 3)) { logger.LogWarning("Latest file is not stable yet: {File}", Path.GetFileName(bestPath)); return null; } FileInfo file; try { file = new FileInfo(bestPath); if (!file.Exists) return null; } catch { return null; } string sha256; try { sha256 = ComputeSha256Hex(bestPath); } catch (Exception ex) { logger.LogWarning(ex, "Cannot compute SHA256 for {File}", file.Name); sha256 = ""; } return new SonexClientVersion( file.Name, "/" + Uri.EscapeDataString(file.Name), bestVersion, file.Length, sha256); } private static bool WaitUntilFileStable(string path, int attempts, int delayMs, int stableChecks) { long lastLength = -1; int stableCount = 0; for (int i = 0; i < attempts; i++) { try { using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) { } long length = new FileInfo(path).Length; if (length == lastLength) { stableCount++; if (stableCount >= stableChecks) return true; } else { lastLength = length; stableCount = 0; } } catch { stableCount = 0; } Thread.Sleep(delayMs); } return false; } private static bool TryExtractVersion(string fileName, out string version) { version = ""; string[] parts = fileName.Split(new[] { '_', '.', '-' }, StringSplitOptions.RemoveEmptyEntries); if (parts.Length != 6) return false; if (!parts[0].Equals("Sonex", StringComparison.OrdinalIgnoreCase)) return false; if (!parts[1].Equals("Client", StringComparison.OrdinalIgnoreCase)) return false; if (!parts[5].Equals("zip", StringComparison.OrdinalIgnoreCase)) return false; version = $"{parts[2]}.{parts[3]}.{parts[4]}"; return true; } private static string ComputeSha256Hex(string filePath) { using var sha256 = SHA256.Create(); using var stream = File.OpenRead(filePath); byte[] hash = sha256.ComputeHash(stream); return Convert.ToHexString(hash).ToLowerInvariant(); } }