Skip to content

Commit

Permalink
Implement auto-update and UpdateChecker module
Browse files Browse the repository at this point in the history
  • Loading branch information
ReMinoer committed Mar 14, 2023
1 parent 46a3dbe commit 8573ded
Show file tree
Hide file tree
Showing 16 changed files with 489 additions and 3 deletions.
25 changes: 25 additions & 0 deletions Calame.AutoUpdate/Calame.AutoUpdate.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>$(TargetFramework)</TargetFramework>
<UseWPF>True</UseWPF>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.1587.40" />
<PackageReference Include="Octokit" Version="5.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Calame\Calame.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Update="WebBrowserDialog.xaml.cs">
<SubType>Code</SubType>
</Compile>
</ItemGroup>
<ItemGroup>
<Page Update="WebBrowserDialog.xaml">
<XamlRuntime>$(DefaultXamlRuntime)</XamlRuntime>
<SubType>Designer</SubType>
</Page>
</ItemGroup>
</Project>
89 changes: 89 additions & 0 deletions Calame.AutoUpdate/GitHubAutoUpdateApi.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using System.Net.Sockets;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using Octokit;

namespace Calame.AutoUpdate
{
// https://octokitnet.readthedocs.io/en/latest/oauth-flow/
// https://github.com/googlesamples/oauth-apps-for-windows/blob/master/OAuthDesktopApp/OAuthDesktopApp/MainWindow.xaml.cs
// https://github.com/IdentityModel/IdentityModel.OidcClient.Samples/blob/main/HttpSysConsoleClient/ConsoleSystemBrowser/Program.cs
static public class GitHubAutoUpdateApi
{
static public (Uri loginUri, string requestState, HttpListener httpListener) GetLoginUri(GitHubClient client, string oauthId)
{
var requestStateBuilder = new StringBuilder(32);
var random = new Random();
for (int i = 0; i < 32; i++)
requestStateBuilder.Append((char)('a' + random.Next(0, 26)));

var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
int port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();

string requestState = requestStateBuilder.ToString();
string redirectUri = $"http://{IPAddress.Loopback}:{port}/";

var loginRequest = new OauthLoginRequest(oauthId)
{
Scopes = { "repo" },
State = requestState,
RedirectUri = new Uri(redirectUri)
};

var httpListener = new HttpListener();
httpListener.Prefixes.Add(redirectUri);
httpListener.Start();

return (client.Oauth.GetGitHubLoginUrl(loginRequest), requestState, httpListener);
}

static public async Task GetAccessTokenAsync(GitHubClient client, string oauthId, string oauthSecret, string requestState, HttpListenerContext httpListenerContext)
{
NameValueCollection queryStringCollection = httpListenerContext.Request.QueryString;

string loginError = queryStringCollection.Get("error");
if (loginError != null)
throw new InvalidOperationException($"OAuth login error: {loginError}");

string returnedState = queryStringCollection.Get("state");
string returnedCode = queryStringCollection.Get("code");

if (returnedCode is null || returnedState is null)
throw new InvalidOperationException($"Malformed authorization response. ({queryStringCollection})");
if (returnedState != requestState)
throw new InvalidOperationException("Invalid authorization response state.");

var tokenRequest = new OauthTokenRequest(oauthId, oauthSecret, returnedCode);
OauthToken token = await client.Oauth.CreateAccessToken(tokenRequest);

if (!string.IsNullOrWhiteSpace(token.ErrorDescription))
throw new InvalidOperationException($"OAuth access token error: {token.ErrorDescription}");

client.Credentials = new Credentials(token.AccessToken);
}

static public async Task<Release> GetLatestRelease(GitHubClient client, string repositoryOwner, string repositoryName)
{
return await client.Repository.Release.GetLatest(repositoryOwner, repositoryName);
}

static public async Task<byte[]> DownloadAsset(GitHubClient client, Release release, string assetName)
{
string assetUrl = release.Assets.FirstOrDefault(x => x.Name == assetName)?.Url;
if (assetUrl is null)
return null;

IApiResponse<object> response = await client.Connection.Get<object>(new Uri(assetUrl), new Dictionary<string, string>(), "application/octet-stream");
byte[] bytes = Encoding.ASCII.GetBytes(response.HttpResponse.Body.ToString());

return bytes;
}
}
}
185 changes: 185 additions & 0 deletions Calame.AutoUpdate/GitHubAutoUpdater.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
using Octokit;
using System.IO;
using System.Net;
using System.Threading.Tasks;
using System.Windows;
using System;
using System.Linq;
using Microsoft.Extensions.Logging;

namespace Calame.AutoUpdate
{
public class GitHubAutoUpdater
{
private const string MessageCaption = "Auto-Update";

static public async Task<string> CheckUpdatesAndAskUserToDownload(IAutoUpdateConfiguration configuration, ILogger logger)
{
GitHubClient gitHubClient = await GetGitHubClientAsync(configuration, logger);
if (gitHubClient is null)
return null;

Release latestRelease = await CheckUpdatesAsync(gitHubClient, configuration, logger);
if (latestRelease is null)
return null;

return await DownloadInstallerAsset(gitHubClient, configuration, latestRelease);
}

static private async Task<GitHubClient> GetGitHubClientAsync(IAutoUpdateConfiguration configuration, ILogger logger)
{
if (configuration?.ProductId is null)
return null;

string message;

var gitHubClient = new GitHubClient(new ProductHeaderValue(configuration.ProductId));

string applicationOAuthId = configuration.ApplicationOAuthId;
string applicationOAuthSecret = configuration.ApplicationOAuthSecret;

if (applicationOAuthId == null || applicationOAuthSecret == null)
return gitHubClient;

Uri loginUri;
string requestState;
HttpListener httpListener;
try
{
(loginUri, requestState, httpListener) = GitHubAutoUpdateApi.GetLoginUri(gitHubClient, applicationOAuthId);
}
catch (Exception ex)
{
message = "Failed to get the login URL on GitHub.";

logger.LogError(ex, message);
MessageBox.Show(message, MessageCaption, MessageBoxButton.OK, MessageBoxImage.Error);
return null;
}

var webBrowserDialog = new WebBrowserDialog(loginUri)
{
Owner = System.Windows.Application.Current.MainWindow,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
Topmost = true,
Width = 600,
Height = 800,
Title = "Connect to GitHub..."
};
webBrowserDialog.Closed += OnClosed;
webBrowserDialog.Show();

void OnClosed(object sender, EventArgs e) => httpListener.Close();

HttpListenerContext httpListenerContext;
try
{
httpListenerContext = await httpListener.GetContextAsync();
}
catch (Exception ex)
{
webBrowserDialog.Close();

message = "Authentication to GitHub failed.";

logger.LogError(ex, message);
MessageBox.Show(message, MessageCaption, MessageBoxButton.OK, MessageBoxImage.Error);
return null;
}

webBrowserDialog.Close();

try
{
await GitHubAutoUpdateApi.GetAccessTokenAsync(gitHubClient, applicationOAuthId, applicationOAuthSecret, requestState, httpListenerContext);
}
catch (Exception ex)
{
message = "Failed to connect to GitHub.";

logger.LogError(ex, message);
MessageBox.Show(message, MessageCaption, MessageBoxButton.OK, MessageBoxImage.Error);
return null;
}

return gitHubClient;
}

static private async Task<Release> CheckUpdatesAsync(GitHubClient gitHubClient, IAutoUpdateConfiguration configuration, ILogger logger)
{
string repositoryOwner = configuration.RepositoryOwner;
string repositoryName = configuration.RepositoryName;
string message;

Release latestRelease;
try
{
latestRelease = await GitHubAutoUpdateApi.GetLatestRelease(gitHubClient, repositoryOwner, repositoryName);
}
catch (Exception ex)
{
message = "Failed to get the latest release tag.";

logger.LogError(ex, message);
MessageBox.Show(message, MessageCaption, MessageBoxButton.OK, MessageBoxImage.Error);
return null;
}

if (CalameUtils.IsDevelopmentBuild())
{
message = $"Latest version is: {latestRelease.TagName}.\n\n(This development build will not be updated.)";

MessageBox.Show(message, MessageCaption, MessageBoxButton.OK, MessageBoxImage.Information);
return null;
}

if (latestRelease.TagName == CalameUtils.GetVersion())
{
message = $"You are using the latest version. ({latestRelease.TagName})";

MessageBox.Show(message, MessageCaption, MessageBoxButton.OK, MessageBoxImage.Information);
return null;
}

if (latestRelease.Assets.All(x => x.Name != configuration.InstallerAssetName))
{
message = $"Latest version is {latestRelease.TagName} but no installer is available yet. Retry to update in a few minutes.";

MessageBox.Show(message, MessageCaption, MessageBoxButton.OK, MessageBoxImage.Information);
return null;
}

MessageBoxResult messageBoxResult = MessageBox.Show($"Do you want to download the last version ({latestRelease.TagName}) ?", MessageCaption,
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.Yes);

return messageBoxResult == MessageBoxResult.Yes ? latestRelease : null;
}

static private async Task<string> DownloadInstallerAsset(GitHubClient gitHubClient, IAutoUpdateConfiguration configuration, Release release)
{
string downloadFolderPath = GetRandomFolderPath(Path.GetTempPath());
string installerAssetName = configuration.InstallerAssetName;
byte[] assetBytes = await GitHubAutoUpdateApi.DownloadAsset(gitHubClient, release, installerAssetName);

string assetFilePath = Path.Combine(downloadFolderPath, installerAssetName);
using (FileStream assetFileStream = File.Create(assetFilePath))
{
await assetFileStream.WriteAsync(assetBytes);
}

return assetFilePath;
}

static private string GetRandomFolderPath(string parentFolderPath)
{
string folderPath;
do
{
folderPath = Path.Combine(parentFolderPath, Path.GetFileNameWithoutExtension(Path.GetRandomFileName()));
}
while (Directory.Exists(folderPath));

return folderPath;
}
}
}
12 changes: 12 additions & 0 deletions Calame.AutoUpdate/IAutoUpdateConfiguration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Calame.AutoUpdate
{
public interface IAutoUpdateConfiguration
{
string ProductId { get; }
string ApplicationOAuthId { get; }
string ApplicationOAuthSecret { get; }
string RepositoryOwner { get; }
string RepositoryName { get; }
string InstallerAssetName { get; }
}
}
12 changes: 12 additions & 0 deletions Calame.AutoUpdate/WebBrowserDialog.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Window x:Class="Calame.AutoUpdate.WebBrowserDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:wpf="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
mc:Ignorable="d"
Title="WebBrowserDialog" Height="450" Width="800">
<Grid>
<wpf:WebView2 x:Name="WebView" />
</Grid>
</Window>
17 changes: 17 additions & 0 deletions Calame.AutoUpdate/WebBrowserDialog.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System;
using System.Windows;

namespace Calame.AutoUpdate
{
/// <summary>
/// Interaction logic for WebBrowserDialog.xaml
/// </summary>
public partial class WebBrowserDialog : Window
{
public WebBrowserDialog(Uri uri)
{
InitializeComponent();
WebView.Source = uri;
}
}
}
1 change: 1 addition & 0 deletions Calame.Icons/Descriptors/CalameIconDescriptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ private double GetPadding(CalameIconKey key)
case CalameIconKey.Reopen:
return 1.5;
case CalameIconKey.Reset:
case CalameIconKey.CheckUpdates:
return 1;
default: return 0;
}
Expand Down
2 changes: 2 additions & 0 deletions Calame.Icons/Providers/CalameIconProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ protected override PackIconMaterialKind GetTargetKey(CalameIconKey key)
{
switch (key)
{
case CalameIconKey.CheckUpdates: return PackIconMaterialKind.PackageDown;

case CalameIconKey.Reopen: return PackIconMaterialKind.Refresh;

case CalameIconKey.BrushPanel: return PackIconMaterialKind.Palette;
Expand Down
Loading

0 comments on commit 8573ded

Please sign in to comment.