using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.NetworkInformation; using System.IO.Compression; using System.IO; using System.Linq; using System.Runtime.Versioning; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text.Json; using System.Threading; using System.Diagnostics; namespace codelanguage; public sealed record ComponentInfo(string ExeName, string RestartArgument); public sealed record UpdateCheckResult { public bool IsAvailable { get; } public bool IsUpdateAvailable => IsAvailable; public Version InstalledVersion { get; } public Version? LatestVersion { get; } public string Title { get; } public string Message { get; } public string? DownloadUrl { get; } public string? UpdateArchiveUrl { get; } public string? UpdateArchiveSha256 { get; } public string? ReleaseNotesUrl { get; } public bool IsCompatibilityNotice { get; } public bool IsOffline { get; } public IReadOnlyDictionary Components { get; } public UpdateCheckResult( bool isAvailable, Version installedVersion, Version? latestVersion, string title, string message, string? downloadUrl, string? updateArchiveUrl, string? updateArchiveSha256, string? releaseNotesUrl, bool isCompatibilityNotice, bool isOffline, IReadOnlyDictionary components) { IsAvailable = isAvailable; InstalledVersion = installedVersion; LatestVersion = latestVersion; Title = title; Message = message; DownloadUrl = downloadUrl; UpdateArchiveUrl = updateArchiveUrl; UpdateArchiveSha256 = updateArchiveSha256; ReleaseNotesUrl = releaseNotesUrl; IsCompatibilityNotice = isCompatibilityNotice; IsOffline = isOffline; Components = components; } public static UpdateCheckResult Offline(Version installedVersion) => new(false, installedVersion, null, "Craft++", "Offline mode: update check skipped.", null, null, null, null, false, true, new Dictionary(StringComparer.OrdinalIgnoreCase)); public static UpdateCheckResult Unavailable(Version installedVersion, HttpStatusCode? statusCode = null, string? message = null) => new(false, installedVersion, null, "Craft++", message ?? (statusCode.HasValue ? $"Update check unavailable (HTTP {(int)statusCode.Value})." : "Update check unavailable."), null, null, null, null, false, false, new Dictionary(StringComparer.OrdinalIgnoreCase)); } /// /// Optional online-update checker. The IDE is fully usable without any network /// access; this service only reports and opens an available update when the /// machine is online and the operator asks to use online features. /// public sealed class ApplicationUpdateService { // Keep the endpoint configurable so an updated build does not have to go through // source changes to point at a different hosting location. private const string DefaultFeed = "https://craftplusplus.216.128.145.79.nip.io/updateddata/version.json"; private const string OfficialUpdateHostName = "craftplusplus.216.128.145.79.nip.io"; private const string OfficialUpdateAddress = "216.128.145.79"; private static readonly string[] OfficialUpdateHostAliases = { OfficialUpdateHostName, "craftplusplus.216-128-145-79.nip.io", "216-128-145-79.nip.io" }; // The Hub currently uses a self-signed TLS certificate. Pin its public key so // update checks remain encrypted and authenticated without accepting arbitrary certificates. private const string HubPublicKeySha256 = "B452C36DFE45280DE7907B8F56492842889CDE1946E35105F01D5E9B471B4CF2"; public static readonly Uri DefaultFeedUri = GetDefaultFeedUri(); private static Uri GetDefaultFeedUri() { var configured = global::System.Environment.GetEnvironmentVariable("CRAFTPLUSPLUS_UPDATE_URL"); return Uri.TryCreate(configured, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps && IsOfficialUpdateHost(uri.Host) && uri.AbsolutePath == "/updateddata/version.json" ? uri : new Uri(DefaultFeed, UriKind.Absolute); } private static readonly Lazy Client = new(() => { HttpClient client = CreateHttpClient(TimeSpan.FromSeconds(5)); client.DefaultRequestHeaders.UserAgent.ParseAdd("Craft++ Update Checker/1.0"); return client; }); private readonly Uri _feedUri; private readonly Func _installedVersionFactory; private readonly string _runtimeIdentifier; public ApplicationUpdateService( Uri? feedUri = null, Func? installedVersionFactory = null) { _feedUri = feedUri ?? DefaultFeedUri; _installedVersionFactory = installedVersionFactory ?? (() => typeof(ApplicationUpdateService).Assembly.GetName().Version ?? new Version(0, 1, 0, 0)); _runtimeIdentifier = BuildRuntimeIdentifier(); } public Uri FeedUri => _feedUri; /// Downloads an update archive into a private staging directory. public async Task DownloadArchiveAsync(string archiveUrl, string? expectedSha256, IProgress? progress = null, CancellationToken cancellationToken = default) { if (!Uri.TryCreate(archiveUrl, UriKind.Absolute, out var uri) || !IsAllowedUpdateUri(uri)) throw new InvalidOperationException("The update archive must use HTTPS or the configured official HTTP update server."); if (string.IsNullOrWhiteSpace(expectedSha256) || expectedSha256.Length != 64 || !expectedSha256.All(Uri.IsHexDigit)) throw new InvalidOperationException("A valid SHA-256 digest is required for update archives."); if (!SameTrustedUpdateHost(uri, _feedUri)) throw new InvalidOperationException($"The update archive URL '{uri}' is not hosted by the trusted update feed '{_feedUri}'."); string? root = null; try { using var downloadClient = CreateHttpClient(TimeSpan.FromMinutes(10)); downloadClient.DefaultRequestHeaders.UserAgent.ParseAdd("Craft++ Update Installer/1.0"); using var response = await downloadClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.NotFound) throw new FileNotFoundException($"The update feed points to an archive that is missing from the update server (HTTP 404): {uri}"); response.EnsureSuccessStatusCode(); var finalUri = response.RequestMessage?.RequestUri; if (finalUri == null || !IsAllowedUpdateUri(finalUri) || !SameTrustedUpdateHost(finalUri, _feedUri)) throw new InvalidDataException($"The update archive redirected away from the trusted update host. Feed: {_feedUri}; archive: {finalUri}."); long? totalBytes = response.Content.Headers.ContentLength; root = CreateUpdateStagingDirectory(totalBytes ?? 0); string zipPath = Path.Combine(root, "update.zip"); await using (var input = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false)) await using (var output = new FileStream(zipPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { byte[] buffer = new byte[8192]; long totalRead = 0; int bytesRead; while ((bytesRead = await input.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) != 0) { await output.WriteAsync(buffer, 0, bytesRead, cancellationToken).ConfigureAwait(false); totalRead += bytesRead; if (totalBytes.HasValue) { progress?.Report((double)totalRead / totalBytes.Value * 100); } } } await using (var archiveStream = File.OpenRead(zipPath)) { byte[] actualHash = await SHA256.HashDataAsync(archiveStream, cancellationToken).ConfigureAwait(false); byte[] expectedHash = Convert.FromHexString(expectedSha256!); if (!CryptographicOperations.FixedTimeEquals(actualHash, expectedHash)) throw new InvalidDataException("The downloaded update archive failed its SHA-256 integrity check."); } long expandedBytes; using (var archive = ZipFile.OpenRead(zipPath)) { expandedBytes = 0; foreach (var entry in archive.Entries) expandedBytes = checked(expandedBytes + entry.Length); } const long extractionReserve = 64L * 1024 * 1024; if (GetAvailableSpace(root) < expandedBytes + extractionReserve) { long compressedBytes = new FileInfo(zipPath).Length; long requiredBytes = checked(compressedBytes + expandedBytes + extractionReserve); string? alternateRoot = TryCreateUpdateStagingDirectory(requiredBytes, root); if (alternateRoot == null) throw new IOException($"Not enough disk space to unpack the Craft++ update. The archive needs about {FormatBytes(expandedBytes)} for extracted files plus {FormatBytes(extractionReserve)} free space. Choose a temporary folder or drive with more free space and try again."); string alternateArchive = Path.Combine(alternateRoot, "update.zip"); try { File.Copy(zipPath, alternateArchive); } catch { TryDeleteDirectory(alternateRoot); throw; } TryDeleteDirectory(root); root = alternateRoot; zipPath = alternateArchive; } string extracted = Path.Combine(root, "extracted"); ZipFile.ExtractToDirectory(zipPath, extracted); string? executable = Directory.EnumerateFiles(extracted, "codelanguage.exe", SearchOption.AllDirectories).FirstOrDefault(); if (executable == null) throw new InvalidDataException("The update archive does not contain codelanguage.exe."); string sourceDirectory = Path.GetDirectoryName(executable)!; string[] requiredFiles = { }; // No longer strictly requiring DLLs as they are bundled in Single-File builds string[] optionalFiles = { "codelanguage.dll", "codelanguage.deps.json", "codelanguage.runtimeconfig.json", Path.Combine("UpdateInstaller", "CraftUpdateInstaller.exe") }; string[] missingRequired = requiredFiles .Where(file => !File.Exists(Path.Combine(sourceDirectory, file))) .ToArray(); if (missingRequired.Length > 0) throw new InvalidDataException("The update archive is missing essential files: " + string.Join(", ", missingRequired)); // Log optional files that are missing, but don't crash the whole process var missingOptional = optionalFiles .Where(file => !File.Exists(Path.Combine(sourceDirectory, file))) .ToList(); if (missingOptional.Any()) { // We'll let the installer handle missing optional files if possible, // or the UI will catch the crash during launch. } return new StagedUpdate(root, sourceDirectory); } catch { TryDeleteDirectory(root); throw; } } private static string CreateUpdateStagingDirectory(long archiveBytes) { long minimumFreeBytes = archiveBytes > 0 ? checked(archiveBytes + 64L * 1024 * 1024) : 0; string? path = TryCreateUpdateStagingDirectory(minimumFreeBytes, excludeRoot: null); if (path != null) return path; throw new IOException($"No writable temporary drive has enough space to download the Craft++ update. At least {FormatBytes(minimumFreeBytes)} free space is required."); } private static string? TryCreateUpdateStagingDirectory(long requiredFreeBytes, string? excludeRoot) { var candidates = new HashSet(StringComparer.OrdinalIgnoreCase); void AddCandidate(string? path) { if (string.IsNullOrWhiteSpace(path)) return; try { candidates.Add(Path.GetFullPath(path)); } catch { } } AddCandidate(Path.GetTempPath()); AddCandidate(Path.Combine(System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData), "Temp")); foreach (var drive in DriveInfo.GetDrives().Where(drive => drive.IsReady && drive.DriveType is DriveType.Fixed or DriveType.Removable)) AddCandidate(Path.Combine(drive.RootDirectory.FullName, "CraftPlusPlusUpdateTemp")); string? excludedVolume = excludeRoot == null ? null : Path.GetPathRoot(Path.GetFullPath(excludeRoot)); foreach (string candidate in candidates .Where(path => excludedVolume == null || !string.Equals(Path.GetPathRoot(path), excludedVolume, StringComparison.OrdinalIgnoreCase)) .OrderByDescending(TryGetAvailableSpace)) { try { if (TryGetAvailableSpace(candidate) < requiredFreeBytes) continue; Directory.CreateDirectory(candidate); if (TryGetAvailableSpace(candidate) < requiredFreeBytes) continue; string root = Path.Combine(candidate, "CraftPlusPlusUpdate_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); return root; } catch { } } return null; } private static long TryGetAvailableSpace(string path) { try { return GetAvailableSpace(path); } catch { return 0; } } private static long GetAvailableSpace(string path) { string? root = Path.GetPathRoot(Path.GetFullPath(path)); var drive = DriveInfo.GetDrives().FirstOrDefault(candidate => candidate.IsReady && string.Equals(candidate.RootDirectory.FullName, root, StringComparison.OrdinalIgnoreCase)); return drive?.AvailableFreeSpace ?? 0; } private static string FormatBytes(long bytes) => $"{bytes / (1024d * 1024 * 1024):0.##} GiB"; public void LaunchInstaller(string installerPath, string sourceDirectory, string installDirectory, string exeName, string restartArgument) { var start = new ProcessStartInfo(installerPath) { UseShellExecute = true, WorkingDirectory = Path.GetDirectoryName(installerPath)! }; start.ArgumentList.Add("--source"); start.ArgumentList.Add(sourceDirectory); start.ArgumentList.Add("--destination"); start.ArgumentList.Add(installDirectory); start.ArgumentList.Add("--executable"); start.ArgumentList.Add(Path.Combine(installDirectory, exeName)); start.ArgumentList.Add("--parent-pid"); start.ArgumentList.Add(System.Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture)); start.ArgumentList.Add("--temp"); start.ArgumentList.Add(Path.GetTempPath()); start.ArgumentList.Add("--restart-argument"); start.ArgumentList.Add(restartArgument); string programFiles = System.Environment.GetFolderPath(System.Environment.SpecialFolder.ProgramFiles); string programFilesX86 = System.Environment.GetFolderPath(System.Environment.SpecialFolder.ProgramFilesX86); if ((!string.IsNullOrWhiteSpace(programFiles) && installDirectory.StartsWith(programFiles, StringComparison.OrdinalIgnoreCase)) || (!string.IsNullOrWhiteSpace(programFilesX86) && installDirectory.StartsWith(programFilesX86, StringComparison.OrdinalIgnoreCase))) { start.Verb = "runas"; } var proc = Process.Start(start); if (proc == null) throw new InvalidOperationException("Windows did not start the Craft++ update installer."); // Caller can handle shutdown after this returns } public static void TryDeleteDirectory(string? path) { if (string.IsNullOrWhiteSpace(path)) return; try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } catch { } } public Func IsNetworkAvailableProvider { get; set; } = () => true; public static Version InstalledVersion => typeof(ApplicationUpdateService).Assembly.GetName().Version ?? new Version(0, 1, 0, 0); public async Task CheckAsync(CancellationToken cancellationToken = default) { if (!IsNetworkAvailableProvider()) { return UpdateCheckResult.Offline(CurrentInstalledVersion); } int maxRetries = 3; int delayMs = 1000; for (int attempt = 1; attempt <= maxRetries; attempt++) { try { using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(TimeSpan.FromSeconds(5)); HttpStatusCode? lastStatus = null; UpdateCheckResult? newestResult = null; foreach (var feedUri in FeedCandidates()) { try { using var request = new HttpRequestMessage(HttpMethod.Get, feedUri); request.Headers.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue { NoCache = true, NoStore = true }; using var response = await Client.Value.SendAsync(request, cts.Token).ConfigureAwait(false); if (response.RequestMessage?.RequestUri is not Uri responseUri || !IsAllowedUpdateUri(responseUri)) continue; if (!response.IsSuccessStatusCode) { lastStatus = response.StatusCode; continue; } string json = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false); var result = ParseFeed(json, CurrentInstalledVersion, _runtimeIdentifier); if (result.LatestVersion is not null && (newestResult?.LatestVersion is null || result.LatestVersion > newestResult.LatestVersion)) { newestResult = result; } } catch { } } // A configured legacy/local feed can still be reachable while // advertising an older release than the official HTTPS feed. // Use the newest valid feed response instead of returning the // first one that answered. if (newestResult is not null) return newestResult; if (attempt == maxRetries) { return UpdateCheckResult.Unavailable(CurrentInstalledVersion, null, $"Could not retrieve an update feed after {maxRetries} attempts. Check your connection."); } } catch { if (attempt == maxRetries) throw; } await Task.Delay(delayMs * attempt, cancellationToken); } return UpdateCheckResult.Unavailable(CurrentInstalledVersion, null, "Update check failed."); } private IEnumerable FeedCandidates() { if (IsAllowedUpdateUri(_feedUri)) yield return _feedUri; // Preserve compatibility with the raw IP alias of the official hostname. if (!string.Equals(_feedUri.Host, OfficialUpdateHostName, StringComparison.OrdinalIgnoreCase) && Uri.TryCreate($"https://{OfficialUpdateHostName}/updateddata/version.json", UriKind.Absolute, out var alias) && IsAllowedUpdateUri(alias)) yield return alias; } // Legacy Windows installations use this explicitly supported HTTP endpoint. private static bool IsAllowedUpdateUri(Uri uri) => (uri.Scheme == Uri.UriSchemeHttps && IsOfficialUpdateHost(uri.Host)) || (uri.Scheme == Uri.UriSchemeHttp && uri.AbsolutePath.StartsWith("/updateddata/", StringComparison.Ordinal) && ((IsOfficialUpdateHost(uri.Host) && (uri.Port == 8787 || uri.Port == 8080)) || (IsLoopbackUpdateHost(uri.Host) && uri.Port == 8787))); private static bool SameTrustedUpdateHost(Uri candidate, Uri feed) { if (IsOfficialUpdateHost(candidate.Host) && IsOfficialUpdateHost(feed.Host)) return string.Equals(candidate.IdnHost, feed.IdnHost, StringComparison.OrdinalIgnoreCase) || string.Equals(NormalizeUpdateHost(candidate.IdnHost), NormalizeUpdateHost(feed.IdnHost), StringComparison.OrdinalIgnoreCase); // A locally configured feed may be used for the legacy local updater, // but its archives must still come from the official HTTPS host. if (IsLoopbackUpdateHost(feed.Host) && feed.Scheme == Uri.UriSchemeHttp && feed.Port == 8787) { if (IsOfficialUpdateHost(candidate.Host)) return candidate.Scheme == Uri.UriSchemeHttps; return IsLoopbackUpdateHost(candidate.Host) && candidate.Scheme == feed.Scheme && candidate.Port == feed.Port; } return false; } public static bool AreTrustedUpdateHosts(Uri feed, Uri archive) => SameTrustedUpdateHost(archive, feed); private static bool IsOfficialUpdateHost(string host) => string.Equals(NormalizeUpdateHost(host), OfficialUpdateAddress, StringComparison.OrdinalIgnoreCase); private static bool IsLoopbackUpdateHost(string host) => string.Equals(host.TrimEnd('.'), "localhost", StringComparison.OrdinalIgnoreCase) || (IPAddress.TryParse(host, out var address) && IPAddress.IsLoopback(address)); private static string NormalizeUpdateHost(string host) { string normalized = host.TrimEnd('.'); return OfficialUpdateHostAliases.Contains(normalized, StringComparer.OrdinalIgnoreCase) ? OfficialUpdateAddress : normalized; } internal static HttpClient CreateHttpClient(TimeSpan timeout) { var handler = new HttpClientHandler { ServerCertificateCustomValidationCallback = (request, certificate, _, errors) => { if (errors == System.Net.Security.SslPolicyErrors.None) return true; if (certificate == null || !IsOfficialUpdateHost(request?.RequestUri?.Host ?? "")) return false; try { using var cert = new X509Certificate2(certificate); var publicKeyHash = Convert.ToHexString(SHA256.HashData(cert.PublicKey.ExportSubjectPublicKeyInfo())); return CryptographicOperations.FixedTimeEquals( Convert.FromHexString(publicKeyHash), Convert.FromHexString(HubPublicKeySha256)); } catch { return false; } } }; return new HttpClient(handler) { Timeout = timeout }; } private Version CurrentInstalledVersion => _installedVersionFactory(); public static UpdateCheckResult ParseFeed(string feedJson, Version installedVersion, string runtimeIdentifier) { if (string.IsNullOrWhiteSpace(feedJson)) { return UpdateCheckResult.Unavailable(installedVersion); } try { using var document = JsonDocument.Parse(feedJson); var root = document.RootElement; if (root.ValueKind != JsonValueKind.Object) { return UpdateCheckResult.Unavailable(installedVersion); } if (!root.TryGetProperty("version", out var versionElement)) { return UpdateCheckResult.Unavailable(installedVersion); } if (!TryParseVersion(versionElement.GetString(), out var latestVersion)) { return UpdateCheckResult.Unavailable(installedVersion); } var title = ReadOptional(root, "title") ?? "Craft++ update available"; // Parse component map if present var componentsDict = new Dictionary(StringComparer.OrdinalIgnoreCase); if (root.TryGetProperty("components", out var comps) && comps.ValueKind == JsonValueKind.Object) { foreach (var prop in comps.EnumerateObject()) { if (prop.Value.ValueKind == JsonValueKind.Object) { componentsDict[prop.Name] = ParseComponent(prop.Value); } } } var downloadUrl = ReadOptional(root, "downloadUrl"); var updateArchiveUrl = ReadOptional(root, "updateArchiveUrl"); var updateArchiveSha256 = ReadOptional(root, "updateArchiveSha256"); var releaseNotesUrl = ReadOptional(root, "releaseNotesUrl"); var message = ReadOptional(root, "message") ?? "A newer Craft++ version is available."; var minimumSupported = ReadOptional(root, "minimumSupportedVersion"); var osCompatible = RuntimeIsCompatible(root, runtimeIdentifier); Version? minimum = null; if (minimumSupported is not null && TryParseVersion(minimumSupported, out minimum)) { osCompatible &= installedVersion >= minimum; } bool isUpdate = latestVersion > installedVersion && osCompatible; return new UpdateCheckResult( isAvailable: isUpdate, installedVersion: installedVersion, latestVersion: latestVersion, title: title, message: message, downloadUrl: downloadUrl, updateArchiveUrl: updateArchiveUrl, updateArchiveSha256: updateArchiveSha256, releaseNotesUrl: releaseNotesUrl, isCompatibilityNotice: !osCompatible && latestVersion > installedVersion, isOffline: false, components: componentsDict); } catch { return UpdateCheckResult.Unavailable(installedVersion); } } private static bool RuntimeIsCompatible(JsonElement root, string runtimeIdentifier) { if (!root.TryGetProperty("platforms", out var platforms) || platforms.ValueKind != JsonValueKind.Array) return true; foreach (var platform in platforms.EnumerateArray()) { var value = platform.GetString(); if (string.IsNullOrWhiteSpace(value)) continue; if (string.Equals(value, runtimeIdentifier, StringComparison.OrdinalIgnoreCase) || runtimeIdentifier.StartsWith(value + "-", StringComparison.OrdinalIgnoreCase)) return true; } return false; } private static ComponentInfo ParseComponent(JsonElement elem) { var exe = elem.GetProperty("exe").GetString() ?? "codelanguage.exe"; var restart = elem.GetProperty("restartArgument").GetString() ?? ""; return new ComponentInfo(exe, restart); } private static string BuildRuntimeIdentifier() { // Keep this broad enough for an update feed to accept the current runtime // without requiring one feed entry per operating-system detail. return OperatingSystem.IsWindows() ? "win" : OperatingSystem.IsLinux() ? "linux" : OperatingSystem.IsMacOS() ? "macos" : "unknown"; } private static bool TryParseVersion(string? text, out Version version) { version = new Version(0, 0, 0, 0); if (string.IsNullOrWhiteSpace(text)) { return false; } string trimmed = text.Trim().TrimStart('v', 'V'); var separator = trimmed.IndexOfAny(['-', '+']); if (separator >= 0) { trimmed = trimmed.Substring(0, separator); } string[] pieces = trimmed.Split('.'); if (pieces.Length == 1) { trimmed += ".0"; } else if (pieces.Length == 2) { trimmed += ".0"; } return Version.TryParse(trimmed, out version); } private static string? ReadOptional(JsonElement element, string propertyName) { return element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String ? property.GetString() : null; } }