diff --git a/ShockOsc/Config/AppConfig.cs b/ShockOsc/Config/AppConfig.cs index 267d166..bcf3867 100644 --- a/ShockOsc/Config/AppConfig.cs +++ b/ShockOsc/Config/AppConfig.cs @@ -1,6 +1,17 @@ -namespace OpenShock.ShockOsc.Config; +using Semver; + +namespace OpenShock.ShockOsc.Config; public sealed class AppConfig { public bool CloseToTray { get; set; } = true; + + public UpdateChannel UpdateChannel { get; set; } = UpdateChannel.Release; + public SemVersion? LastIgnoredVersion { get; set; } = null; +} + +public enum UpdateChannel +{ + Release, + PreRelease } \ No newline at end of file diff --git a/ShockOsc/Config/ShockOscConfig.cs b/ShockOsc/Config/ShockOscConfig.cs index 71dcdbe..02c4eae 100644 --- a/ShockOsc/Config/ShockOscConfig.cs +++ b/ShockOsc/Config/ShockOscConfig.cs @@ -9,7 +9,6 @@ public sealed class ShockOscConfig public OpenShockConf OpenShock { get; set; } = new(); public ChatboxConf Chatbox { get; set; } = new(); public IDictionary Groups { get; set; } = new Dictionary(); - public SemVersion? LastIgnoredVersion { get; set; } = null; public AppConfig App { get; set; } = new(); } \ No newline at end of file diff --git a/ShockOsc/Models/GithubReleaseResponse.cs b/ShockOsc/Models/GithubReleaseResponse.cs index fb73f07..11dac2d 100644 --- a/ShockOsc/Models/GithubReleaseResponse.cs +++ b/ShockOsc/Models/GithubReleaseResponse.cs @@ -6,6 +6,16 @@ public class GithubReleaseResponse { [JsonPropertyName("tag_name")] public required string TagName { get; set; } + + [JsonPropertyName("id")] + public required ulong Id { get; set; } + + [JsonPropertyName("draft")] + public required bool Draft { get; set; } + + [JsonPropertyName("prerelease")] + public required bool Prerelease { get; set; } + [JsonPropertyName("assets")] public required ICollection Assets { get; set; } diff --git a/ShockOsc/Services/ShockOsc.cs b/ShockOsc/Services/ShockOsc.cs index 066f770..59a13eb 100644 --- a/ShockOsc/Services/ShockOsc.cs +++ b/ShockOsc/Services/ShockOsc.cs @@ -129,6 +129,8 @@ public async Task FoundVrcClient(IPEndPoint? oscClient) _logger.LogInformation("Ready"); OsTask.Run(_underscoreConfig.SendUpdateForAll); + + _oscClient.SendChatboxMessage($"{_configManager.Config.Chatbox.Prefix} Game Connected"); } public async Task OnAvatarChange(Dictionary parameters, string avatarId) diff --git a/ShockOsc/Services/Updater.cs b/ShockOsc/Services/Updater.cs index 6346eaf..2906ead 100644 --- a/ShockOsc/Services/Updater.cs +++ b/ShockOsc/Services/Updater.cs @@ -5,31 +5,35 @@ using OpenShock.SDK.CSharp.Updatables; using OpenShock.ShockOsc.Config; using OpenShock.ShockOsc.Models; -using OpenShock.ShockOsc.Ui.Utils; +using OpenShock.ShockOsc.Utils; using Semver; namespace OpenShock.ShockOsc.Services; public sealed class Updater { - private const string GithubLatest = "https://api.github.com/repos/OpenShock/ShockOsc/releases/latest"; + private const string GithubReleasesUrl = "https://api.github.com/repos/OpenShock/ShockOsc/releases"; + private const string GithubLatest = $"{GithubReleasesUrl}/latest"; private const string SetupFileName = "ShockOSC_Setup.exe"; // OpenShock.ShockOsc.exe private static readonly HttpClient HttpClient = new(); private readonly string _setupFilePath = Path.Combine(Path.GetTempPath(), SetupFileName); - private static readonly SemVersion CurrentVersion = SemVersion.Parse(typeof(Updater).Assembly.GetCustomAttribute()!.InformationalVersion, SemVersionStyles.Strict); + private static readonly SemVersion CurrentVersion = SemVersion.Parse( + typeof(Updater).Assembly.GetCustomAttribute()!.InformationalVersion, + SemVersionStyles.Strict); - private Uri? LatestDownloadUrl { get; set; } + private Uri? ReleaseDownloadUrl { get; set; } private readonly ILogger _logger; private readonly ConfigManager _configManager; - + public UpdatableVariable CheckingForUpdate { get; } = new(false); public UpdatableVariable UpdateAvailable { get; } = new(false); public bool IsPostponed { get; private set; } public SemVersion? LatestVersion { get; private set; } + public UpdatableVariable DownloadProgress { get; } = new(0); public Updater(ILogger logger, ConfigManager configManager) @@ -53,44 +57,41 @@ private static bool TryDeleteFile(string fileName) } } - private async Task<(SemVersion, GithubReleaseResponse.Asset)?> GetLatestRelease() + private async Task<(SemVersion, GithubReleaseResponse.Asset)?> GetRelease() { - _logger.LogInformation("Checking GitHub for updates..."); + var updateChannel = _configManager.Config.App.UpdateChannel; + _logger.LogInformation("Checking GitHub for updates on channel {UpdateChannel}", updateChannel); try { - var res = await HttpClient.GetAsync(GithubLatest); - if (!res.IsSuccessStatusCode) + var release = updateChannel switch { - _logger.LogWarning("Failed to get latest version information from GitHub. {StatusCode}", - res.StatusCode); - return null; - } + UpdateChannel.Release => await GetLatestRelease(), + UpdateChannel.PreRelease => await GetPreRelease(), + _ => null + }; - var json = - await JsonSerializer.DeserializeAsync(await res.Content.ReadAsStreamAsync()); - if (json == null) + if (release == null) { - _logger.LogWarning("Could not deserialize json"); + _logger.LogError("Failed to get latest version information from GitHub"); return null; } - var tagName = json.TagName; - - if (!SemVersion.TryParse(tagName, SemVersionStyles.AllowV, out var version)) + if (!SemVersion.TryParse(release.TagName, SemVersionStyles.AllowV, out var version)) { - _logger.LogWarning("Failed to parse version. Value: {Version}", json.TagName); + _logger.LogWarning("Failed to parse version. Value: {Version}", release.TagName); return null; } - var asset = json.Assets.FirstOrDefault(x => x.Name.Equals(SetupFileName, StringComparison.InvariantCultureIgnoreCase)); + var asset = release.Assets.FirstOrDefault(x => + x.Name.Equals(SetupFileName, StringComparison.InvariantCultureIgnoreCase)); if (asset == null) { _logger.LogWarning("Could not find asset with {@SetupName}. Assets found: {@Assets}", SetupFileName, - json.Assets); + release.Assets); return null; } - + return (version, asset); } catch (Exception e) @@ -100,12 +101,88 @@ private static bool TryDeleteFile(string fileName) } } + private async Task GetPreRelease() + { + using var res = await HttpClient.GetAsync(GithubReleasesUrl); + if (!res.IsSuccessStatusCode) + { + _logger.LogWarning("Failed to get latest version information from GitHub. {StatusCode}", + res.StatusCode); + return null; + } + + var json = + await JsonSerializer.DeserializeAsync>( + await res.Content.ReadAsStreamAsync()); + if (json == null) + { + _logger.LogWarning("Could not deserialize json"); + return null; + } + + var listOfValid = new List<(GithubReleaseResponse, SemVersion)>(); + foreach (var release in json.Where(x => x.Prerelease)) + { + var tagName = release.TagName; + if (!SemVersion.TryParse(tagName, SemVersionStyles.AllowV, out var version)) + { + _logger.LogDebug("Failed to parse version. Value: {Version}", tagName); + continue; + } + + listOfValid.Add((release, version)); + } + + var newestPreRelease = listOfValid.OrderByDescending(x => x.Item2).FirstOrDefault(); + + return newestPreRelease.Item1; + } + + private async Task GetLatestRelease() + { + using var res = await HttpClient.GetAsync(GithubLatest); + if (!res.IsSuccessStatusCode) + { + _logger.LogWarning("Failed to get latest version information from GitHub. {StatusCode}", + res.StatusCode); + return null; + } + + var json = + await JsonSerializer.DeserializeAsync(await res.Content.ReadAsStreamAsync()); + if (json == null) + { + _logger.LogWarning("Could not deserialize json"); + return null; + } + + return json; + } + + private readonly SemaphoreSlim _updateLock = new(1, 1); + public async Task CheckUpdate() + { + await _updateLock.WaitAsync(); + + try + { + CheckingForUpdate.Value = true; + await CheckUpdateInternal(); + } + finally + { + _updateLock.Release(); + CheckingForUpdate.Value = false; + } + } + + private async Task CheckUpdateInternal() { IsPostponed = false; UpdateAvailable.Value = false; - - var latestVersion = await GetLatestRelease(); + + var latestVersion = await GetRelease(); if (latestVersion == null) { UpdateAvailable.Value = false; @@ -123,8 +200,9 @@ public async Task CheckUpdate() UpdateAvailable.Value = true; LatestVersion = latestVersion.Value.Item1; - LatestDownloadUrl = latestVersion.Value.Item2.BrowserDownloadUrl; - if (_configManager.Config.LastIgnoredVersion != null && _configManager.Config.LastIgnoredVersion.ComparePrecedenceTo(latestVersion.Value.Item1) >= 0) + ReleaseDownloadUrl = latestVersion.Value.Item2.BrowserDownloadUrl; + if (_configManager.Config.App.LastIgnoredVersion != null && + _configManager.Config.App.LastIgnoredVersion.ComparePrecedenceTo(latestVersion.Value.Item1) >= 0) { _logger.LogInformation( "ShockOsc is not up to date. Skipping update due to previous postpone"); @@ -140,7 +218,9 @@ public async Task CheckUpdate() public async Task DoUpdate() { _logger.LogInformation("Starting update..."); - if (LatestVersion == null || LatestDownloadUrl == null) + + DownloadProgress.Value = 0; + if (LatestVersion == null || ReleaseDownloadUrl == null) { _logger.LogError("LatestVersion or LatestDownloadUrl is null. Cannot update"); return; @@ -150,12 +230,20 @@ public async Task DoUpdate() _logger.LogDebug("Downloading new release..."); var sp = Stopwatch.StartNew(); - await using (var stream = await HttpClient.GetStreamAsync(LatestDownloadUrl)) + var download = await HttpClient.GetAsync(ReleaseDownloadUrl, HttpCompletionOption.ResponseHeadersRead); + var totalBytes = download.Content.Headers.ContentLength ?? 1; + + await using (var stream = await download.Content.ReadAsStreamAsync()) { await using var fStream = new FileStream(_setupFilePath, FileMode.OpenOrCreate); - await stream.CopyToAsync(fStream); + var relativeProgress = new Progress(downloadedBytes => + DownloadProgress.Value = ((double)downloadedBytes / totalBytes) * 100); + + // Use extension method to report progress while downloading + await stream.CopyToAsync(fStream, 81920, relativeProgress); } + DownloadProgress.Value = 100; _logger.LogDebug("Downloaded file within {TimeTook}ms", sp.ElapsedMilliseconds); _logger.LogInformation("Download complete, now restarting to newer application in one second"); await Task.Delay(1000); diff --git a/ShockOsc/ShockOsc.csproj b/ShockOsc/ShockOsc.csproj index a62b52a..2e85344 100644 --- a/ShockOsc/ShockOsc.csproj +++ b/ShockOsc/ShockOsc.csproj @@ -16,7 +16,7 @@ OpenShock.ShockOsc OpenShock 2.0.0 - 2.0.0-rc.2 + 2.0.0-rc.3 Resources\openshock-icon.ico true ShockOsc diff --git a/ShockOsc/Ui/Pages/Dash/Components/UpdateDialog.razor b/ShockOsc/Ui/Pages/Dash/Components/UpdateDialog.razor index 85aede1..ae1acd5 100644 --- a/ShockOsc/Ui/Pages/Dash/Components/UpdateDialog.razor +++ b/ShockOsc/Ui/Pages/Dash/Components/UpdateDialog.razor @@ -3,20 +3,31 @@ @using OpenShock.ShockOsc.Utils @inject ConfigManager ConfigManager @inject Updater Updater +@implements IDisposable @code { - [CascadingParameter] - MudDialogInstance MudDialog { get; set; } + [CascadingParameter] MudDialogInstance MudDialog { get; set; } private bool _isDownloading = false; + protected override void OnInitialized() + { + Updater.DownloadProgress.OnValueChanged += OnDownloadProgressChanged; + base.OnInitialized(); + } + + private void OnDownloadProgressChanged(double progress) + { + InvokeAsync(StateHasChanged); + } + private async Task Skip() { - ConfigManager.Config.LastIgnoredVersion = Updater.LatestVersion; + ConfigManager.Config.App.LastIgnoredVersion = Updater.LatestVersion; await ConfigManager.SaveAsync(); MudDialog.Close(DialogResult.Ok(true)); } - + private void Dismiss() { MudDialog.Close(DialogResult.Ok(true)); @@ -27,31 +38,46 @@ _isDownloading = true; OsTask.Run(Updater.DoUpdate); } + + public void Dispose() + { + Updater.DownloadProgress.OnValueChanged -= OnDownloadProgressChanged; + } + } +
+ @if (!_isDownloading) + { + Update Available + @Updater.LatestVersion?.ToString() + +
+ A new version of ShockOSC is available.Would you like to update ? + } + else + { + Downloading Update +
+ Please wait while the update is downloaded. +
+ + + @if (Math.Abs(Updater.DownloadProgress.Value - 100) < 0.0001) + { + Update downloaded successfully. Restarting in one second... + } + } +
+
+ @if (!_isDownloading) { - Update Available -
- A new version of ShockOSC is available. Would you like to update? - } - else - { - Downloading Update -
- Please wait while the update is downloaded. -
- + Dismiss + Skip + Update } - - - @if (!_isDownloading) - { - Dismiss - Skip - Update - }
\ No newline at end of file diff --git a/ShockOsc/Ui/Pages/Dash/Components/UpdateLogout.razor b/ShockOsc/Ui/Pages/Dash/Components/UpdateLogout.razor index d663f99..f1c262c 100644 --- a/ShockOsc/Ui/Pages/Dash/Components/UpdateLogout.razor +++ b/ShockOsc/Ui/Pages/Dash/Components/UpdateLogout.razor @@ -5,6 +5,7 @@ @inject ConfigManager ConfigManager @inject Updater Updater @inject NavigationManager NavigationManager +@implements IDisposable @code { private readonly DialogOptions _dialogOptions = new() { NoHeader = true, DisableBackdropClick = true }; @@ -16,23 +17,43 @@ protected override async Task OnInitializedAsync() { - Updater.UpdateAvailable.OnValueChanged += v => - { - InvokeAsync(StateHasChanged); - if (v && !Updater.IsPostponed) OpenUpdateDialog(); - }; + Updater.UpdateAvailable.OnValueChanged += UpdateAvailableOnValueChanged; + Updater.CheckingForUpdate.OnValueChanged += CheckingForUpdateOnValueChanged; if (Updater.UpdateAvailable.Value && !Updater.IsPostponed) OpenUpdateDialog(); - + } + + private void UpdateAvailableOnValueChanged(bool v) + { + InvokeAsync(StateHasChanged); + if (v && !Updater.IsPostponed) OpenUpdateDialog(); + } + + private void CheckingForUpdateOnValueChanged(bool v) + { + InvokeAsync(StateHasChanged); } private async Task Logout() { ConfigManager.Config.OpenShock.Token = string.Empty; await ConfigManager.SaveAsync(); - + NavigationManager.NavigateTo("/"); } + + public void Dispose() + { + Updater.CheckingForUpdate.OnValueChanged -= CheckingForUpdateOnValueChanged; + Updater.UpdateAvailable.OnValueChanged -= UpdateAvailableOnValueChanged; + } + + private string GetUpdateTooltip() + { + if(Updater.CheckingForUpdate.Value) return "Checking for updates..."; + return Updater.UpdateAvailable.Value ? "Update available!" : "You are up-to-date!"; + } + }
@@ -44,9 +65,16 @@ - + - + @if (Updater.CheckingForUpdate.Value) + { + + } + else + { + + } diff --git a/ShockOsc/Ui/Pages/Dash/Tabs/AppSettingsTab.razor b/ShockOsc/Ui/Pages/Dash/Tabs/AppSettingsTab.razor index ff6de6d..26eae0a 100644 --- a/ShockOsc/Ui/Pages/Dash/Tabs/AppSettingsTab.razor +++ b/ShockOsc/Ui/Pages/Dash/Tabs/AppSettingsTab.razor @@ -1,5 +1,8 @@ @using OpenShock.ShockOsc.Config +@using OpenShock.ShockOsc.Services @inject ConfigManager ConfigManager +@inject Updater Updater +@implements IDisposable @page "/dash/appsettings" @@ -7,8 +10,26 @@ ShockOSC App - - +
+ + + +
+ + @foreach (UpdateChannel channel in Enum.GetValues(typeof(UpdateChannel))) + { + @channel + } + +
+ + @if (Updater.CheckingForUpdate.Value) + { + + + + } +
@@ -18,17 +39,38 @@ @if (!ConfigManager.Config.Osc.OscQuery) { -
- - +
+ + }
@code { - - + + protected override void OnInitialized() + { + Updater.CheckingForUpdate.OnValueChanged += OnCheckingForUpdateChange; + } + + private void OnCheckingForUpdateChange(bool value) + { + InvokeAsync(StateHasChanged); + } + private async Task OnSettingsValueChange() { await ConfigManager.SaveAsync(); } + + private async Task UpdateChannelChanged() + { + await OnSettingsValueChange(); + await Updater.CheckUpdate(); + } + + public void Dispose() + { + Updater.CheckingForUpdate.OnValueChanged -= OnCheckingForUpdateChange; + } + } \ No newline at end of file diff --git a/ShockOsc/Utils/StreamExtensions.cs b/ShockOsc/Utils/StreamExtensions.cs new file mode 100644 index 0000000..d901069 --- /dev/null +++ b/ShockOsc/Utils/StreamExtensions.cs @@ -0,0 +1,24 @@ +namespace OpenShock.ShockOsc.Utils; + +public static class StreamExtensions +{ + public static async Task CopyToAsync(this Stream? source, Stream? destination, uint bufferSize, IProgress? progress = null, CancellationToken cancellationToken = default) { + if (source == null) + throw new ArgumentNullException(nameof(source)); + if (!source.CanRead) + throw new ArgumentException("Has to be readable", nameof(source)); + if (destination == null) + throw new ArgumentNullException(nameof(destination)); + if (!destination.CanWrite) + throw new ArgumentException("Has to be writable", nameof(destination)); + + var buffer = new byte[bufferSize]; + long totalBytesRead = 0; + int bytesRead; + while ((bytesRead = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) != 0) { + await destination.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false); + totalBytesRead += bytesRead; + progress?.Report(totalBytesRead); + } + } +} \ No newline at end of file