diff --git a/GestureWheel/App.xaml b/GestureWheel/App.xaml index cecc6b9..2bc6b77 100644 --- a/GestureWheel/App.xaml +++ b/GestureWheel/App.xaml @@ -3,7 +3,8 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" - Startup="App_OnStartup" Exit="App_OnExit"> + Exit="App_OnExit" + Startup="App_OnStartup"> diff --git a/GestureWheel/App.xaml.cs b/GestureWheel/App.xaml.cs index 402a719..cb52b82 100644 --- a/GestureWheel/App.xaml.cs +++ b/GestureWheel/App.xaml.cs @@ -7,6 +7,7 @@ using System.Windows.Controls; using GestureWheel.Managers; using GestureWheel.Supports; +using GestureWheel.Utilities; using Hardcodet.Wpf.TaskbarNotification; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; @@ -38,7 +39,6 @@ private void InitializeUserInterface() _taskbarIcon = new TaskbarIcon { IsEnabled = true, - ToolTipText = "GestureWheel", ContextMenu = new ContextMenu(), Icon = Icon.ExtractAssociatedIcon(EnvironmentSupport.Executable) }; @@ -48,8 +48,8 @@ private void InitializeUserInterface() _taskbarIcon.ContextMenu.Items.Add(CreateMenuItem("프로그램 설정", ShowWithActivate, SymbolRegular.Wrench20)); - _taskbarIcon.ContextMenu.Items.Add(CreateMenuItem("업데이트 확인", - () => Process.Start("https://github.com/iodes/GestureWheel"), SymbolRegular.Earth20)); + _taskbarIcon.ContextMenu.Items.Add(CreateMenuItem("홈페이지 방문", + () => UrlUtility.Open("https://github.com/iodes/GestureWheel"), SymbolRegular.Open20)); _taskbarIcon.ContextMenu.Items.Add(new Separator()); @@ -96,6 +96,18 @@ private void App_OnStartup(object sender, StartupEventArgs e) if (Environment.GetCommandLineArgs().Contains("/Activate", StringComparer.OrdinalIgnoreCase)) ShowWithActivate(); + + if (SettingsManager.Current.UseAutoUpdate) + { + try + { + _ = UpdateSupport.CheckUpdateAsync(); + } + catch + { + // ignored + } + } } private void App_OnExit(object sender, ExitEventArgs e) diff --git a/GestureWheel/Dialogs/UpdateDialog.xaml b/GestureWheel/Dialogs/UpdateDialog.xaml new file mode 100644 index 0000000..add81ce --- /dev/null +++ b/GestureWheel/Dialogs/UpdateDialog.xaml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GestureWheel/Dialogs/UpdateDialog.xaml.cs b/GestureWheel/Dialogs/UpdateDialog.xaml.cs new file mode 100644 index 0000000..68b836e --- /dev/null +++ b/GestureWheel/Dialogs/UpdateDialog.xaml.cs @@ -0,0 +1,69 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Windows; +using GestureWheel.Extensions; +using GestureWheel.Windows.Models; + +namespace GestureWheel.Dialogs +{ + public partial class UpdateDialog + { + #region Properties + private UpdateInfo Info { get; } + #endregion + + public UpdateDialog(UpdateInfo info) + { + Info = info; + InitializeComponent(); + + TextVersion.Text = $"새로운 버전이 출시되었습니다.\n{nameof(GestureWheel)}을 {info.Version} 버전으로 업데이트 하시겠습니까?"; + } + + private async void BtnUpdate_Click(object sender, RoutedEventArgs e) + { + BtnCancel.IsEnabled = false; + BtnUpdate.IsEnabled = false; + + var fileName = Path.Combine(Path.GetTempPath(), Info.FileName); + + using (var client = new HttpClient()) + await using (var file = new FileStream(fileName, FileMode.Create, FileAccess.Write, FileShare.None)) + { + var progress = new Progress(); + + progress.ProgressChanged += (_, value) => + { + Dispatcher.Invoke(() => ProgressUpdate.Value = value); + }; + + await client.DownloadAsync(Info.Url, file, progress); + } + + try + { + Process.Start(new ProcessStartInfo(fileName) + { + UseShellExecute = true + }); + + Application.Current.Shutdown(); + } + catch (Exception ex) + { + MessageBox.Show(ex.Message, "오류", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + Close(); + } + } + + private void BtnCancel_Click(object sender, RoutedEventArgs e) + { + Close(); + } + } +} diff --git a/GestureWheel/Extensions/HttpClientExtensions.cs b/GestureWheel/Extensions/HttpClientExtensions.cs new file mode 100644 index 0000000..efa6650 --- /dev/null +++ b/GestureWheel/Extensions/HttpClientExtensions.cs @@ -0,0 +1,30 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace GestureWheel.Extensions +{ + internal static class HttpClientExtensions + { + public static async Task DownloadAsync(this HttpClient client, string requestUri, Stream destination, IProgress progress = null, CancellationToken cancellationToken = default) + { + using var response = await client.GetAsync(requestUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + + var contentLength = response.Content.Headers.ContentLength; + await using var download = await response.Content.ReadAsStreamAsync(cancellationToken); + + if (progress is null || !contentLength.HasValue) + { + await download.CopyToAsync(destination, cancellationToken); + return; + } + + var relativeProgress = new Progress(totalBytes => progress.Report((double)totalBytes / contentLength.Value * 100)); + await download.CopyToAsync(destination, 81920, relativeProgress, cancellationToken); + + progress.Report(100); + } + } +} diff --git a/GestureWheel/Extensions/StreamExtensions.cs b/GestureWheel/Extensions/StreamExtensions.cs new file mode 100644 index 0000000..45f2e8e --- /dev/null +++ b/GestureWheel/Extensions/StreamExtensions.cs @@ -0,0 +1,39 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace GestureWheel.Extensions +{ + internal static class StreamExtensions + { + public static async Task CopyToAsync(this Stream source, Stream destination, int 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)); + + if (bufferSize < 0) + throw new ArgumentOutOfRangeException(nameof(bufferSize)); + + var buffer = new byte[bufferSize]; + long totalBytesRead = 0; + int bytesRead; + + while ((bytesRead = await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) != 0) + { + await destination.WriteAsync(buffer, 0, bytesRead, cancellationToken).ConfigureAwait(false); + totalBytesRead += bytesRead; + progress?.Report(totalBytesRead); + } + } + } +} diff --git a/GestureWheel/Managers/SettingsManager.cs b/GestureWheel/Managers/SettingsManager.cs index 6f1a555..7917f14 100644 --- a/GestureWheel/Managers/SettingsManager.cs +++ b/GestureWheel/Managers/SettingsManager.cs @@ -9,11 +9,11 @@ namespace GestureWheel.Managers internal static class SettingsManager { #region Fields - private static SettingsModel _current; + private static Settings _current; #endregion #region Properties - public static SettingsModel Current + public static Settings Current { get { @@ -30,12 +30,12 @@ public static void Load() { if (!File.Exists(EnvironmentSupport.Settings)) { - _current = new SettingsModel(); + _current = new Settings(); Save(); } var settingsText = File.ReadAllText(EnvironmentSupport.Settings); - _current = JsonConvert.DeserializeObject(settingsText); + _current = JsonConvert.DeserializeObject(settingsText); } public static void UpdateAutoStartup() diff --git a/GestureWheel/Models/SettingsModel.cs b/GestureWheel/Models/Settings.cs similarity index 78% rename from GestureWheel/Models/SettingsModel.cs rename to GestureWheel/Models/Settings.cs index 715dc66..3ed45ec 100644 --- a/GestureWheel/Models/SettingsModel.cs +++ b/GestureWheel/Models/Settings.cs @@ -2,8 +2,11 @@ namespace GestureWheel.Models { - internal class SettingsModel + internal class Settings { + [JsonProperty] + public bool UseAutoUpdate { get; set; } = true; + [JsonProperty] public bool UseAutoStartup { get; set; } = true; diff --git a/GestureWheel/Models/UpdateInfo.cs b/GestureWheel/Models/UpdateInfo.cs new file mode 100644 index 0000000..cd3844f --- /dev/null +++ b/GestureWheel/Models/UpdateInfo.cs @@ -0,0 +1,17 @@ +using System; + +namespace GestureWheel.Windows.Models +{ + public class UpdateInfo + { + public string Version { get; set; } + + public DateTime Timestamp { get; set; } + + public string ReleaseNote { get; set; } + + public string FileName { get; set; } + + public string Url { get; set; } + } +} diff --git a/GestureWheel/Pages/AboutPage.xaml b/GestureWheel/Pages/AboutPage.xaml index 126e8ea..56b7090 100644 --- a/GestureWheel/Pages/AboutPage.xaml +++ b/GestureWheel/Pages/AboutPage.xaml @@ -20,7 +20,37 @@ FontSize="13" FontWeight="Medium" Text="프로그램 버전" /> - + + + + + + + + + + + + + + + + + + + diff --git a/GestureWheel/Pages/AboutPage.xaml.cs b/GestureWheel/Pages/AboutPage.xaml.cs index 9f820d9..81c4b51 100644 --- a/GestureWheel/Pages/AboutPage.xaml.cs +++ b/GestureWheel/Pages/AboutPage.xaml.cs @@ -1,10 +1,44 @@ -namespace GestureWheel.Pages +using System.Reflection; +using System.Windows; +using GestureWheel.Supports; +using GestureWheel.Utilities; + +namespace GestureWheel.Pages { public partial class AboutPage { + #region Constructor public AboutPage() { InitializeComponent(); + TextVersion.Text = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0.0"; } + #endregion + + #region Private Events + private async void BtnCheckUpdate_OnClick(object sender, RoutedEventArgs e) + { + BtnCheckUpdate.IsEnabled = false; + + try + { + if (!await UpdateSupport.CheckUpdateAsync()) + MessageBox.Show("이미 최신 버전을 사용하고 있습니다.", "업데이트 없음", MessageBoxButton.OK, MessageBoxImage.Information); + } + catch + { + MessageBox.Show("업데이트 확인을 실패했습니다.\n잠시 후 다시 시도해주세요.", "업데이트 확인 실패", MessageBoxButton.OK, MessageBoxImage.Warning); + } + finally + { + BtnCheckUpdate.IsEnabled = true; + } + } + + private void BtnOpenHomepage_OnClick(object sender, RoutedEventArgs e) + { + UrlUtility.Open("https://github.com/iodes/GestureWheel"); + } + #endregion } } diff --git a/GestureWheel/Pages/SettingsPage.xaml b/GestureWheel/Pages/SettingsPage.xaml index fea9292..aa7069d 100644 --- a/GestureWheel/Pages/SettingsPage.xaml +++ b/GestureWheel/Pages/SettingsPage.xaml @@ -73,5 +73,17 @@ + + + + + + + + + diff --git a/GestureWheel/Pages/SettingsPage.xaml.cs b/GestureWheel/Pages/SettingsPage.xaml.cs index 5d6cf59..2f6845c 100644 --- a/GestureWheel/Pages/SettingsPage.xaml.cs +++ b/GestureWheel/Pages/SettingsPage.xaml.cs @@ -13,6 +13,12 @@ public SettingsPage() Loaded += OnLoaded; + ToggleAutoUpdate.CheckChanged += delegate + { + SettingsManager.Current.UseAutoUpdate = ToggleAutoUpdate.IsChecked; + SettingsManager.Save(); + }; + ToggleAutoStartup.CheckChanged += delegate { SettingsManager.Current.UseAutoStartup = ToggleAutoStartup.IsChecked; @@ -43,6 +49,7 @@ public SettingsPage() #region Private Events private void OnLoaded(object sender, RoutedEventArgs e) { + ToggleAutoUpdate.IsChecked = SettingsManager.Current.UseAutoUpdate; ToggleAutoStartup.IsChecked = SettingsManager.Current.UseAutoStartup; ToggleQuickNewDesktop.IsChecked = SettingsManager.Current.UseQuickNewDesktop; ComboDoubleClickActionType.SelectedIndex = Math.Max(0, Math.Min(ComboDoubleClickActionType.Items.Count - 1, SettingsManager.Current.DoubleClickActionType)); diff --git a/GestureWheel/Supports/UpdateSupport.cs b/GestureWheel/Supports/UpdateSupport.cs new file mode 100644 index 0000000..e8e9663 --- /dev/null +++ b/GestureWheel/Supports/UpdateSupport.cs @@ -0,0 +1,92 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Reflection; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using GestureWheel.Dialogs; +using GestureWheel.Windows.Models; +using Newtonsoft.Json.Linq; + +namespace GestureWheel.Supports +{ + internal static class UpdateSupport + { + #region Fields + private static readonly HttpClient _httpClient = new(); + private static readonly Regex _assetRegex = new(@$"{nameof(GestureWheel)}_(\d+\.?\d+\.?\d+\.?\d+?)_Setup\.exe"); + #endregion + + #region Constants + private const string repository = "https://api.github.com/repos/iodes/GestureWheel/releases"; + #endregion + + #region Constructor + static UpdateSupport() + { + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + _httpClient.DefaultRequestHeaders.UserAgent.TryParseAdd("request"); + } + #endregion + + #region Public Methods + public static async Task CheckUpdateAsync() + { + var info = await GetLatestUpdateInfo(); + + if (info is null) + return false; + + var currentVersion = int.Parse(Assembly.GetExecutingAssembly().GetName().Version?.ToString().Replace(".", string.Empty) ?? "0"); + var latestVersion = int.Parse(info.Version?.Replace(".", string.Empty) ?? "0"); + + if (latestVersion > currentVersion) + { + var updateDialog = new UpdateDialog(info); + updateDialog.ShowDialog(); + } + + return true; + } + + public static async Task GetLatestUpdateInfo() + { + var json = await _httpClient.GetStringAsync(repository); + var releases = JArray.Parse(json); + + foreach (var release in releases) + { + var info = new UpdateInfo + { + Version = release["tag_name"]?.Value(), + Timestamp = release["published_at"]?.Value() ?? default, + ReleaseNote = release["body"]?.Value() + }; + + var assets = release["assets"]; + + if (assets is null) + continue; + + foreach (var asset in assets) + { + var name = asset["name"]?.Value() ?? string.Empty; + var nameMatch = _assetRegex.Match(name); + + if (!nameMatch.Success) + continue; + + info.FileName = name; + info.Url = asset["browser_download_url"]?.Value(); + break; + } + + if (!string.IsNullOrEmpty(info.Url)) + return info; + } + + return null; + } + #endregion + } +} diff --git a/GestureWheel/Utilities/UrlUtility.cs b/GestureWheel/Utilities/UrlUtility.cs new file mode 100644 index 0000000..6d95111 --- /dev/null +++ b/GestureWheel/Utilities/UrlUtility.cs @@ -0,0 +1,25 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace GestureWheel.Utilities +{ + internal static class UrlUtility + { + public static void Open(string url) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + url = url.Replace("&", "^&"); + Process.Start(new ProcessStartInfo("cmd", $"/c start {url}") { CreateNoWindow = true }); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + Process.Start("xdg-open", url); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + Process.Start("open", url); + } + } + } +}