From 2dcd778be5cb2f6d624e7d49c870e34c703d21b3 Mon Sep 17 00:00:00 2001 From: Nathan zhou Date: Wed, 11 Mar 2026 12:35:57 -0400 Subject: [PATCH 01/12] ui remake --- .../CSAUSBTool.CrossPlatform.Desktop.csproj | 12 +- CSAUSBTool.CrossPlatform.Desktop/config.json | 3 + .../CSAUSBTool.CrossPlatform.sln | 24 + .../Models/ControlSystemSoftware.cs | 146 ++-- .../ViewModels/MainWindowViewModel.cs | 651 +++++++++++++++++- .../Views/MainWindow.axaml | 71 +- .../Views/MainWindow.axaml.cs | 110 ++- 7 files changed, 919 insertions(+), 98 deletions(-) create mode 100644 CSAUSBTool.CrossPlatform.Desktop/config.json create mode 100644 CSAUSBTool.CrossPlatform/CSAUSBTool.CrossPlatform.sln diff --git a/CSAUSBTool.CrossPlatform.Desktop/CSAUSBTool.CrossPlatform.Desktop.csproj b/CSAUSBTool.CrossPlatform.Desktop/CSAUSBTool.CrossPlatform.Desktop.csproj index 8d1facc..ae2f79c 100644 --- a/CSAUSBTool.CrossPlatform.Desktop/CSAUSBTool.CrossPlatform.Desktop.csproj +++ b/CSAUSBTool.CrossPlatform.Desktop/CSAUSBTool.CrossPlatform.Desktop.csproj @@ -1,8 +1,6 @@ - + WinExe - net8.0 enable true @@ -12,14 +10,16 @@ app.manifest true - - - + reuven.ico + + + Always + diff --git a/CSAUSBTool.CrossPlatform.Desktop/config.json b/CSAUSBTool.CrossPlatform.Desktop/config.json new file mode 100644 index 0000000..7e9a3e2 --- /dev/null +++ b/CSAUSBTool.CrossPlatform.Desktop/config.json @@ -0,0 +1,3 @@ +{ + "repo_api_lists_url": "https://api.github.com/repos/JamieSinn/CSA-USB-Tool/contents/Lists" +} diff --git a/CSAUSBTool.CrossPlatform/CSAUSBTool.CrossPlatform.sln b/CSAUSBTool.CrossPlatform/CSAUSBTool.CrossPlatform.sln new file mode 100644 index 0000000..3cfe045 --- /dev/null +++ b/CSAUSBTool.CrossPlatform/CSAUSBTool.CrossPlatform.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSAUSBTool.CrossPlatform", "CSAUSBTool.CrossPlatform.csproj", "{75FFD5CF-8C16-F521-0919-9F39B01CE15F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {75FFD5CF-8C16-F521-0919-9F39B01CE15F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {75FFD5CF-8C16-F521-0919-9F39B01CE15F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {75FFD5CF-8C16-F521-0919-9F39B01CE15F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {75FFD5CF-8C16-F521-0919-9F39B01CE15F}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {0F4532DC-CDDD-4BDC-97C9-67893ED1F27A} + EndGlobalSection +EndGlobal diff --git a/CSAUSBTool.CrossPlatform/Models/ControlSystemSoftware.cs b/CSAUSBTool.CrossPlatform/Models/ControlSystemSoftware.cs index 6e42f33..bc4b16c 100644 --- a/CSAUSBTool.CrossPlatform/Models/ControlSystemSoftware.cs +++ b/CSAUSBTool.CrossPlatform/Models/ControlSystemSoftware.cs @@ -1,9 +1,9 @@ -using System; +using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Linq; using System.Security.Cryptography; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using CSAUSBTool.CrossPlatform.Core; @@ -13,76 +13,128 @@ namespace CSAUSBTool.CrossPlatform.Models { public class ControlSystemSoftware : ReactiveObject { - public string Name { get; set; } + [JsonPropertyName("Name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("FileName")] public string? FileName { get; set; } - public string Description { get; set; } - public List Tags { get; set; } - public string Uri { get; set; } + + [JsonPropertyName("Description")] + public string Description { get; set; } = string.Empty; + + [JsonPropertyName("Tags")] + public List Tags { get; set; } = []; + + [JsonPropertyName("Uri")] + public string? Uri { get; set; } + + [JsonPropertyName("Hash")] public string? Hash { get; set; } - public string Platform { get; set; } - private double _DownloadProgress; + [JsonPropertyName("Platform")] + public string? Platform { get; set; } + + private double _downloadProgress; public double DownloadProgress { - get => _DownloadProgress; - set => this.RaiseAndSetIfChanged(ref _DownloadProgress, value); + get => _downloadProgress; + set => this.RaiseAndSetIfChanged(ref _downloadProgress, value); } - public ControlSystemSoftware() + private bool _isChecked; + public bool IsChecked { + get => _isChecked; + set => this.RaiseAndSetIfChanged(ref _isChecked, value); } - public async Task Download(string outputPath, CancellationToken token) + private string _statusText = "Pending"; + public string StatusText + { + get => _statusText; + set => this.RaiseAndSetIfChanged(ref _statusText, value); + } + + private string _displayText = string.Empty; + public string DisplayText + { + get => _displayText; + set => this.RaiseAndSetIfChanged(ref _displayText, value); + } + + [JsonIgnore] + public bool IsSelectable => !string.IsNullOrWhiteSpace(Uri); + + public void RefreshDisplayText() { - if (Uri == null) + var tags = Tags.Count > 0 ? $" [{string.Join(", ", Tags)}]" : string.Empty; + DisplayText = IsSelectable ? $"{Name}{tags}" : $"{Name}{tags} (no download URI)"; + } + + public string ResolveFileName() + { + if (!string.IsNullOrWhiteSpace(FileName)) { - throw new ArgumentNullException("Uri", "must not be null"); + return FileName!; } - FileName ??= Uri.Split('/').Last(); - var outputUri = new Uri(new Uri(outputPath), FileName); - try + if (!string.IsNullOrWhiteSpace(Uri)) { - await using var existingFile = File.OpenRead(System.Uri.UnescapeDataString(outputUri.AbsolutePath)); - - if (existingFile is { Length: > 0 }) + var parsed = new global::System.Uri(Uri); + var fromUrl = global::System.Uri.UnescapeDataString(Path.GetFileName(parsed.LocalPath)); + if (!string.IsNullOrWhiteSpace(fromUrl)) { - if (Hash != null) - { - var currentHash = CalculateMD5(existingFile); - if (currentHash == Hash) - { - DownloadProgress = 100; - return; - } - - File.Delete(outputUri.AbsolutePath); - } + return fromUrl; } } - catch (FileNotFoundException e) + + return $"{Name}.bin"; + } + + // Legacy compatibility for older views still calling Download(). + public async Task Download(string outputPath, CancellationToken token) + { + if (string.IsNullOrWhiteSpace(Uri)) { - // Silently catch this - this is ignored. + throw new InvalidOperationException("No download URI."); } - - using var client = - new HttpClientDownloadWithProgress(Uri, outputUri.AbsolutePath); - //client.ProgressChanged += _8kbBuffer; - client.ProgressChanged += UpdateProgress; + var resolvedName = ResolveFileName(); + var outputFile = Path.Combine(outputPath, resolvedName); + using var client = new HttpClientDownloadWithProgress(Uri, outputFile); + client.ProgressChanged += (_, _, p) => DownloadProgress = p ?? 0; await client.StartDownload(token); } - private void UpdateProgress(long? totalFileSize, long totalBytesDownloaded, double? progressPercentage) + public static string CalculateHash(string filePath, string algorithmName) { - Debug.WriteLine($"Downloaded {totalBytesDownloaded} of {totalFileSize} - {progressPercentage}%"); - DownloadProgress = progressPercentage ?? 0; + using var stream = File.OpenRead(filePath); + + byte[] hash = algorithmName switch + { + "MD5" => MD5.HashData(stream), + "SHA1" => SHA1.HashData(stream), + "SHA256" => SHA256.HashData(stream), + _ => throw new InvalidOperationException($"Invalid hash algorithm: {algorithmName}") + }; + + return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); } - private string CalculateMD5(FileStream stream) + + public static string? GetHashAlgorithmFromLength(string? hash) { - using var md5 = MD5.Create(); - var hash = md5.ComputeHash(stream); - return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(hash)) + { + return null; + } + + return hash.Length switch + { + 32 => "MD5", + 40 => "SHA1", + 64 => "SHA256", + _ => null + }; } } @@ -93,6 +145,8 @@ public DesignControlSystemSoftware() Name = "FRC Driver Station"; Tags = ["Driver Station", "FRC"]; Description = "The FRC Driver Station is the software used to control your robot during a match."; + Uri = "https://example.com/driverstation.exe"; + RefreshDisplayText(); } } -} \ No newline at end of file +} diff --git a/CSAUSBTool.CrossPlatform/ViewModels/MainWindowViewModel.cs b/CSAUSBTool.CrossPlatform/ViewModels/MainWindowViewModel.cs index 14a58dd..b2f181c 100644 --- a/CSAUSBTool.CrossPlatform/ViewModels/MainWindowViewModel.cs +++ b/CSAUSBTool.CrossPlatform/ViewModels/MainWindowViewModel.cs @@ -1,71 +1,648 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.Linq; -using System.Net; using System.Net.Http; -using System.Reactive; -using System.Text; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; +using System.Threading; using System.Threading.Tasks; -using Avalonia.Controls; +using Avalonia.Threading; +using CSAUSBTool.CrossPlatform.Models; using ReactiveUI; -namespace CSAUSBTool.CrossPlatform.ViewModels +namespace CSAUSBTool.CrossPlatform.ViewModels; + +public class MainWindowViewModel : ViewModelBase { - internal class MainWindowViewModel : ViewModelBase + private const string DefaultRepoApiListsUrl = "https://api.github.com/repos/JamieSinn/CSA-USB-Tool/contents/Lists"; + + private readonly HttpClient _httpClient; + private readonly Dictionary _jsonPathToUrl = new(StringComparer.OrdinalIgnoreCase); + private CancellationTokenSource? _operationCts; + + public ObservableCollection JsonFiles { get; } = []; + public ObservableCollection TagOptions { get; } = ["All Tags"]; + public ObservableCollection MaxParallelOptions { get; } = [1, 2, 3, 4, 5, 6]; + public ObservableCollection SoftwareItems { get; } = []; + + private string? _selectedJsonFile; + public string? SelectedJsonFile + { + get => _selectedJsonFile; + set => this.RaiseAndSetIfChanged(ref _selectedJsonFile, value); + } + + private string _selectedTag = "All Tags"; + public string SelectedTag + { + get => _selectedTag; + set => this.RaiseAndSetIfChanged(ref _selectedTag, value); + } + + private int _maxParallelDownloads = 3; + public int MaxParallelDownloads + { + get => _maxParallelDownloads; + set => this.RaiseAndSetIfChanged(ref _maxParallelDownloads, value); + } + + private string _downloadFolder = string.Empty; + public string DownloadFolder + { + get => _downloadFolder; + set + { + this.RaiseAndSetIfChanged(ref _downloadFolder, value); + this.RaisePropertyChanged(nameof(HasDownloadFolder)); + } + } + + public bool HasDownloadFolder => !string.IsNullOrWhiteSpace(DownloadFolder); + + private string _statusText = "Step 1: Fetch JSON list from repository."; + public string StatusText { - public List ProgramYears { get; set; } = []; - public ObservableCollection Programs { get; } + get => _statusText; + set => this.RaiseAndSetIfChanged(ref _statusText, value); + } - public MainWindowViewModel() + private bool _isBusy; + public bool IsBusy + { + get => _isBusy; + set { - InitializeProgramLists(); - Programs = new ObservableCollection(GetPrograms()); + this.RaiseAndSetIfChanged(ref _isBusy, value); + this.RaisePropertyChanged(nameof(CanDownload)); + this.RaisePropertyChanged(nameof(CanVerify)); + } + } + public bool CanDownload => !IsBusy && HasDownloadFolder && SoftwareItems.Any(s => s.IsChecked && s.IsSelectable); + public bool CanVerify => !IsBusy && HasDownloadFolder && SoftwareItems.Count > 0; + + public MainWindowViewModel() + { + _httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("CSA-USB-Tool-CrossPlatform/1.0"); + } + + public async Task FetchJsonListAsync() + { + if (IsBusy) + { + return; } - private List GetPrograms() + IsBusy = true; + StatusText = "Fetching JSON list from repository..."; + JsonFiles.Clear(); + _jsonPathToUrl.Clear(); + + try { - return ProgramYears.Select(py => py.Program).Distinct().Select(program => new MenuItemViewModel() + var repoApiUrl = LoadRepoApiListsUrl(); + var results = new List<(string Path, string DownloadUrl)>(); + await CollectJsonFilesRecursiveAsync(repoApiUrl, results); + + foreach (var item in results.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase)) { - Header = program, - MenuItems = new ObservableCollection(GetProgramYears(program)) - }).ToList(); + JsonFiles.Add(item.Path); + _jsonPathToUrl[item.Path] = item.DownloadUrl; + } + + StatusText = results.Count > 0 + ? $"Step 2: Select JSON and load. Found {results.Count} files." + : "No JSON files found under configured Lists URL."; + } + catch (Exception ex) + { + StatusText = "Failed to fetch JSON list."; + throw new InvalidOperationException($"Failed to fetch JSON list: {ex.Message}", ex); + } + finally + { + IsBusy = false; + } + } + + public async Task LoadSelectedJsonAsync() + { + if (IsBusy) + { + return; + } + + if (string.IsNullOrWhiteSpace(SelectedJsonFile) || !_jsonPathToUrl.TryGetValue(SelectedJsonFile, out var downloadUrl)) + { + throw new InvalidOperationException("Please select a JSON file first."); } - private List GetProgramYears(string program) + IsBusy = true; + StatusText = $"Loading software from {SelectedJsonFile}..."; + + try { - return ProgramYears.Where(py => py.Program == program).Select(py => new MenuItemViewModel() + await using var stream = await _httpClient.GetStreamAsync(downloadUrl); + var season = await JsonSerializer.DeserializeAsync(stream, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + SoftwareItems.Clear(); + TagOptions.Clear(); + TagOptions.Add("All Tags"); + + if (season?.Software == null || season.Software.Count == 0) + { + StatusText = "No software entries were found in selected JSON."; + this.RaisePropertyChanged(nameof(CanDownload)); + this.RaisePropertyChanged(nameof(CanVerify)); + return; + } + + foreach (var software in season.Software) { - Header = py.Year.ToString(), - Command = ReactiveCommand.Create(() => HandleYearSelection(py)), + software.DownloadProgress = 0; + software.IsChecked = false; + software.StatusText = "Pending"; + software.RefreshDisplayText(); + SoftwareItems.Add(software); + } + + var allTags = SoftwareItems + .SelectMany(s => s.Tags) + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(t => t, StringComparer.OrdinalIgnoreCase); + + foreach (var tag in allTags) + { + TagOptions.Add(tag); + } + + SelectedTag = "All Tags"; + StatusText = $"Step 3: Select software with checkboxes. Loaded {SoftwareItems.Count} entries."; + this.RaisePropertyChanged(nameof(CanDownload)); + this.RaisePropertyChanged(nameof(CanVerify)); + } + catch (Exception ex) + { + StatusText = "Failed to load selected JSON."; + throw new InvalidOperationException($"Failed to load selected JSON: {ex.Message}", ex); + } + finally + { + IsBusy = false; + } + } + + public void ToggleTagSelection(string selectedTag) + { + if (string.IsNullOrWhiteSpace(selectedTag) || selectedTag == "All Tags") + { + return; + } + + foreach (var item in SoftwareItems.Where(s => s.IsSelectable && s.Tags.Contains(selectedTag, StringComparer.OrdinalIgnoreCase))) + { + item.IsChecked = !item.IsChecked; + } + + this.RaisePropertyChanged(nameof(CanDownload)); + } + + public void SelectAll() + { + foreach (var item in SoftwareItems.Where(s => s.IsSelectable)) + { + item.IsChecked = true; + } + this.RaisePropertyChanged(nameof(CanDownload)); + } + + public void DeselectAll() + { + foreach (var item in SoftwareItems) + { + item.IsChecked = false; + } + this.RaisePropertyChanged(nameof(CanDownload)); + } + + public async Task DownloadSelectedAsync() + { + if (IsBusy) + { + return; + } + + if (!HasDownloadFolder) + { + throw new InvalidOperationException("Please select a download folder first."); + } + + var selected = SoftwareItems.Where(s => s.IsChecked && s.IsSelectable).ToList(); + if (selected.Count == 0) + { + throw new InvalidOperationException("Please select at least one downloadable item."); + } + + IsBusy = true; + StatusText = $"Downloading {selected.Count} selected items..."; + _operationCts = new CancellationTokenSource(); + var token = _operationCts.Token; + + foreach (var item in selected) + { + item.DownloadProgress = 0; + item.StatusText = "Queued"; + } + + var successful = new HashSet(); + PreventSystemSleep(); + + try + { + var semaphore = new SemaphoreSlim(MaxParallelDownloads); + var tasks = selected.Select(async item => + { + await semaphore.WaitAsync(token); + try + { + await DownloadItemWithRetryAsync(item, token); + successful.Add(item); + } + catch (OperationCanceledException) + { + SetItemStatus(item, "Canceled"); + throw; + } + catch (Exception ex) + { + SetItemStatus(item, $"Failed: {ex.Message}"); + } + finally + { + semaphore.Release(); + } }).ToList(); + + try + { + await Task.WhenAll(tasks); + } + catch (OperationCanceledException) + { + StatusText = "Download canceled."; + } + + if (!token.IsCancellationRequested) + { + foreach (var item in successful) + { + item.IsChecked = false; + } + } + + var failedCount = selected.Count - successful.Count; + StatusText = token.IsCancellationRequested + ? $"Download canceled. Success: {successful.Count}, Failed: {failedCount}" + : $"Download finished. Success: {successful.Count}, Failed: {failedCount}"; + } + finally + { + AllowSystemSleep(); + IsBusy = false; + _operationCts?.Dispose(); + _operationCts = null; + this.RaisePropertyChanged(nameof(CanDownload)); + this.RaisePropertyChanged(nameof(CanVerify)); + } + } + + public async Task VerifyMd5Async() + { + if (IsBusy) + { + return; } - private void HandleYearSelection(ProgramYearViewModel yearViewModel) + if (!HasDownloadFolder) { - System.Diagnostics.Debug.WriteLine($"{yearViewModel.Year} was selected with the program of {yearViewModel.Program}"); + throw new InvalidOperationException("Please select a download folder first."); } - public void InitializeProgramLists() + if (SoftwareItems.Count == 0) { - using var client = new HttpClient(); - var years = client - .GetStringAsync( - "https://raw.githubusercontent.com/JamieSinn/CSA-USB-Tool/refs/heads/main/Years.txt").Result; + throw new InvalidOperationException("Load a JSON file first."); + } - var yearList = years.Split("\n").ToList(); - yearList.ForEach(line => + IsBusy = true; + StatusText = "Verifying MD5 hashes..."; + _operationCts = new CancellationTokenSource(); + var token = _operationCts.Token; + + var ok = 0; + var fail = 0; + var missing = 0; + var failedItems = new List(); + + try + { + foreach (var item in SoftwareItems) { - var program = line[..3]; - var year = line[3..7]; - var programYear = new ProgramYearViewModel(int.Parse(year), program); - if (programYear.SoftwareGroups.Count > 0) + token.ThrowIfCancellationRequested(); + + if (string.IsNullOrWhiteSpace(item.FileName) || string.IsNullOrWhiteSpace(item.Hash)) { - ProgramYears.Add(programYear); + SetItemStatus(item, "No MD5 metadata"); + continue; } - }); + + if (item.Hash.Length != 32) + { + SetItemStatus(item, "Non-MD5 hash"); + continue; + } + + var filePath = Path.Combine(DownloadFolder, item.FileName); + if (!File.Exists(filePath)) + { + SetItemStatus(item, "Missing file"); + missing++; + failedItems.Add(item); + continue; + } + + SetItemStatus(item, "Verifying"); + await Task.Run(() => + { + using var md5 = MD5.Create(); + using var stream = File.OpenRead(filePath); + var hash = md5.ComputeHash(stream); + var actual = BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); + if (actual == item.Hash.ToLowerInvariant()) + { + SetItemProgress(item, 100); + SetItemStatus(item, "MD5 OK"); + Interlocked.Increment(ref ok); + } + else + { + SetItemStatus(item, "MD5 Mismatch"); + Interlocked.Increment(ref fail); + lock (failedItems) + { + failedItems.Add(item); + } + } + }, token); + } + + foreach (var item in failedItems.Distinct()) + { + item.IsChecked = item.IsSelectable; + } + + StatusText = $"Verify complete. OK: {ok}, Fail: {fail}, Missing: {missing}"; + } + catch (OperationCanceledException) + { + StatusText = "Verify canceled."; + } + finally + { + IsBusy = false; + _operationCts?.Dispose(); + _operationCts = null; + this.RaisePropertyChanged(nameof(CanDownload)); + this.RaisePropertyChanged(nameof(CanVerify)); + } + } + + public void CancelCurrentOperation() + { + _operationCts?.Cancel(); + } + + private async Task DownloadItemWithRetryAsync(ControlSystemSoftware item, CancellationToken token) + { + var maxAttempts = 5; + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + token.ThrowIfCancellationRequested(); + try + { + await DownloadItemAsync(item, token); + return; + } + catch (OperationCanceledException) + { + throw; + } + catch when (attempt < maxAttempts) + { + SetItemStatus(item, $"Retry {attempt}/{maxAttempts}"); + await Task.Delay(TimeSpan.FromSeconds(attempt * 2), token); + } } + + throw new InvalidOperationException("Download failed after retries."); } -} \ No newline at end of file + + private async Task DownloadItemAsync(ControlSystemSoftware item, CancellationToken token) + { + if (string.IsNullOrWhiteSpace(item.Uri)) + { + throw new InvalidOperationException("No download URI."); + } + + var safeFileName = SanitizeFileName(item.ResolveFileName()); + var finalPath = Path.Combine(DownloadFolder, safeFileName); + var partPath = finalPath + ".part"; + + SetItemStatus(item, "Downloading"); + SetItemProgress(item, 0); + + Directory.CreateDirectory(DownloadFolder); + + var existingSize = File.Exists(partPath) ? new FileInfo(partPath).Length : 0L; + + using var request = new HttpRequestMessage(HttpMethod.Get, item.Uri); + if (existingSize > 0) + { + request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(existingSize, null); + } + + using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token); + response.EnsureSuccessStatusCode(); + + if (existingSize > 0 && response.StatusCode == System.Net.HttpStatusCode.OK) + { + File.Delete(partPath); + existingSize = 0; + } + + await using var input = await response.Content.ReadAsStreamAsync(token); + await using var output = new FileStream( + partPath, + existingSize > 0 ? FileMode.Append : FileMode.Create, + FileAccess.Write, + FileShare.None, + 1024 * 1024, + useAsync: true); + + var contentLength = response.Content.Headers.ContentLength ?? 0; + var totalExpected = existingSize + contentLength; + var downloaded = existingSize; + + var buffer = new byte[1024 * 1024]; + while (true) + { + token.ThrowIfCancellationRequested(); + var read = await input.ReadAsync(buffer, token); + if (read == 0) + { + break; + } + + await output.WriteAsync(buffer.AsMemory(0, read), token); + downloaded += read; + if (totalExpected > 0) + { + var percent = (downloaded * 100d) / totalExpected; + SetItemProgress(item, percent); + } + } + + output.Close(); + if (File.Exists(finalPath)) + { + File.Delete(finalPath); + } + File.Move(partPath, finalPath); + + var algo = ControlSystemSoftware.GetHashAlgorithmFromLength(item.Hash); + if (!string.IsNullOrWhiteSpace(item.Hash) && algo != null) + { + SetItemStatus(item, $"Verifying {algo}"); + var actualHash = await Task.Run(() => ControlSystemSoftware.CalculateHash(finalPath, algo), token); + if (!actualHash.Equals(item.Hash, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Hash mismatch."); + } + } + + SetItemProgress(item, 100); + SetItemStatus(item, "Success"); + } + + private async Task CollectJsonFilesRecursiveAsync(string apiUrl, List<(string Path, string DownloadUrl)> results) + { + using var stream = await _httpClient.GetStreamAsync(apiUrl); + using var doc = await JsonDocument.ParseAsync(stream); + if (doc.RootElement.ValueKind != JsonValueKind.Array) + { + return; + } + + foreach (var entry in doc.RootElement.EnumerateArray()) + { + var type = entry.TryGetProperty("type", out var typeProp) ? typeProp.GetString() : null; + if (string.Equals(type, "file", StringComparison.OrdinalIgnoreCase)) + { + var name = entry.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null; + var path = entry.TryGetProperty("path", out var pathProp) ? pathProp.GetString() : null; + var downloadUrl = entry.TryGetProperty("download_url", out var downloadProp) ? downloadProp.GetString() : null; + + if (!string.IsNullOrWhiteSpace(name) && name.EndsWith(".json", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(path) && !string.IsNullOrWhiteSpace(downloadUrl)) + { + results.Add((path, downloadUrl)); + } + } + else if (string.Equals(type, "dir", StringComparison.OrdinalIgnoreCase)) + { + var childUrl = entry.TryGetProperty("url", out var urlProp) ? urlProp.GetString() : null; + if (!string.IsNullOrWhiteSpace(childUrl)) + { + await CollectJsonFilesRecursiveAsync(childUrl, results); + } + } + } + } + + private static string SanitizeFileName(string name) + { + var invalid = Path.GetInvalidFileNameChars(); + return string.Concat(name.Select(c => invalid.Contains(c) ? '_' : c)); + } + + private string LoadRepoApiListsUrl() + { + var candidates = new[] + { + Path.Combine(AppContext.BaseDirectory, "config.json"), + Path.Combine(Environment.CurrentDirectory, "config.json") + }; + + foreach (var path in candidates) + { + if (!File.Exists(path)) + { + continue; + } + + using var stream = File.OpenRead(path); + using var doc = JsonDocument.Parse(stream); + if (doc.RootElement.TryGetProperty("repo_api_lists_url", out var value)) + { + var url = value.GetString(); + if (!string.IsNullOrWhiteSpace(url)) + { + return url; + } + } + } + + return DefaultRepoApiListsUrl; + } + + private static void SetItemProgress(ControlSystemSoftware item, double value) + { + Dispatcher.UIThread.Post(() => item.DownloadProgress = value); + } + + private static void SetItemStatus(ControlSystemSoftware item, string status) + { + Dispatcher.UIThread.Post(() => item.StatusText = status); + } + + [DllImport("kernel32.dll")] + private static extern uint SetThreadExecutionState(uint esFlags); + + private const uint EsContinuous = 0x80000000; + private const uint EsSystemRequired = 0x00000001; + + private static void PreventSystemSleep() + { + if (OperatingSystem.IsWindows()) + { + SetThreadExecutionState(EsContinuous | EsSystemRequired); + } + } + + private static void AllowSystemSleep() + { + if (OperatingSystem.IsWindows()) + { + SetThreadExecutionState(EsContinuous); + } + } +} + + diff --git a/CSAUSBTool.CrossPlatform/Views/MainWindow.axaml b/CSAUSBTool.CrossPlatform/Views/MainWindow.axaml index 6f046d1..2ba8e28 100644 --- a/CSAUSBTool.CrossPlatform/Views/MainWindow.axaml +++ b/CSAUSBTool.CrossPlatform/Views/MainWindow.axaml @@ -1,16 +1,75 @@ - - + + + + +