diff --git a/.gitignore b/.gitignore index b196242c5a..29a0534fd9 100644 --- a/.gitignore +++ b/.gitignore @@ -85,4 +85,4 @@ InstallerExtras/MsiCreator/UniGetUISetup.msi src/global.json UniGetUI.Installer.ms-store-test.exe UniGetUI Installer_winget-fix-test.exe -InstallerExtras/uninst-*.e32 +InstallerExtras/uninst-*.e32 \ No newline at end of file diff --git a/scripts/apply_versions.py b/scripts/apply_versions.py index 066979cbf1..9ab530a8ab 100644 --- a/scripts/apply_versions.py +++ b/scripts/apply_versions.py @@ -78,6 +78,7 @@ def fileReplaceLinesWith(filename: str, list: dict[str, str], encoding="utf-8"): }, encoding="utf-8-sig") print("done!") + except FileNotFoundError as e: print(f"Error: {e.strerror}: {e.filename}") os.system("pause") diff --git a/src/UniGetUI.Core.SecureSettings/SecureGHTokenManager.cs b/src/UniGetUI.Core.SecureSettings/SecureGHTokenManager.cs new file mode 100644 index 0000000000..1e0ba6be92 --- /dev/null +++ b/src/UniGetUI.Core.SecureSettings/SecureGHTokenManager.cs @@ -0,0 +1,73 @@ +using Windows.Security.Credentials; +using UniGetUI.Core.Logging; + +namespace UniGetUI.Core.SecureSettings +{ + public static class SecureGHTokenManager + { + private const string GitHubResourceName = "UniGetUI/GitHubAccessToken"; + private static readonly string UserName = Environment.UserName; + + public static void StoreToken(string token) + { + if (string.IsNullOrEmpty(token)) + { + Logger.Warn("Attempted to store a null or empty token. Operation cancelled."); + return; + } + + var vault = new PasswordVault(); + var newCredential = new PasswordCredential(GitHubResourceName, UserName, token); + + try + { + if (GetToken() is not null) + { + DeleteToken(); + } + } + catch + { + // ignore + } + + vault.Add(newCredential); + Logger.Info("GitHub access token stored/updated securely."); + } + + public static string? GetToken() + { + try + { + var vault = new PasswordVault(); + var credential = vault.Retrieve(GitHubResourceName, UserName); + credential.RetrievePassword(); + Logger.Debug("GitHub access token retrieved."); + return credential.Password; + } + catch (Exception ex) + { + Logger.Warn($"Could not retrieve token (it may not exist): {ex.Message}"); + return null; + } + } + + public static void DeleteToken() + { + var vault = new PasswordVault(); + var credentials = vault.FindAllByResource(GitHubResourceName); + if (credentials.Count > 0) + { + foreach (var cred in credentials) + { + vault.Remove(cred); + } + Logger.Info("GitHub access token deleted."); + } + else + { + Logger.Info("No GitHub access token found to delete."); + } + } + } +} diff --git a/src/UniGetUI.Core.Settings/SettingsEngine_ImportExport.cs b/src/UniGetUI.Core.Settings/SettingsEngine_ImportExport.cs index be8b1da2e7..77c3dea479 100644 --- a/src/UniGetUI.Core.Settings/SettingsEngine_ImportExport.cs +++ b/src/UniGetUI.Core.Settings/SettingsEngine_ImportExport.cs @@ -6,21 +6,12 @@ namespace UniGetUI.Core.SettingsEngine; public partial class Settings { - public static void ExportToJSON(string path) + public static void ExportToFile_JSON(string path) { - Dictionary settings = []; - foreach (string entry in Directory.EnumerateFiles(CoreData.UniGetUIUserConfigurationDirectory)) - { - if(new[] {"OperationHistory", "WinGetAlreadyUpgradedPackages.json", "TelemetryClientToken", "CurrentSessionToken"}.Contains(entry.Split("\\")[^1])) - continue; - - settings.Add(Path.GetFileName(entry), File.ReadAllText(entry)); - } - - File.WriteAllText(path, JsonSerializer.Serialize(settings, SerializationOptions)); + File.WriteAllText(path, ExportToString_JSON()); } - public static void ImportFromJSON(string path) + public static void ImportFromFile_JSON(string path) { if (Path.GetDirectoryName(path) == CoreData.UniGetUIUserConfigurationDirectory) { @@ -29,16 +20,34 @@ public static void ImportFromJSON(string path) File.Copy(path, newPath); path = newPath; } + ImportFromString_JSON(path); + } + + public static string ExportToString_JSON() + { + Dictionary settings = []; + foreach (string entry in Directory.EnumerateFiles(CoreData.UniGetUIUserConfigurationDirectory)) + { + if (new[] { "OperationHistory", "WinGetAlreadyUpgradedPackages.json", "TelemetryClientToken", "CurrentSessionToken" }.Contains(Path.GetFileName(entry))) + continue; + + settings.Add(Path.GetFileName(entry), File.ReadAllText(entry)); + } + return JsonSerializer.Serialize(settings, SerializationOptions); + } + public static void ImportFromString_JSON(string jsonContent) + { ResetSettings(); - Dictionary settings = JsonSerializer.Deserialize>(File.ReadAllText(path), SerializationOptions) ?? []; + Dictionary settings = JsonSerializer.Deserialize>(jsonContent, SerializationOptions) ?? []; foreach (KeyValuePair entry in settings) { - if(new[] {"OperationHistory", "WinGetAlreadyUpgradedPackages.json", "TelemetryClientToken", "CurrentSessionToken"}.Contains(entry.Key)) + if (new[] { "OperationHistory", "WinGetAlreadyUpgradedPackages.json", "TelemetryClientToken", "CurrentSessionToken" }.Contains(entry.Key)) continue; File.WriteAllText(Path.Join(CoreData.UniGetUIUserConfigurationDirectory, entry.Key), entry.Value); } + Logger.Info("Settings successfully imported from string content."); } public static void ResetSettings() diff --git a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs index 2c01b4decb..fd6e395058 100644 --- a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs +++ b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs @@ -39,7 +39,8 @@ public enum K AlreadyWarnedAboutAdmin, ShownTelemetryBanner, CollapseNavMenuOnWideScreen, - EnablePackageBackup, + EnablePackageBackup_LOCAL, + EnablePackageBackup_CLOUD, ChangeBackupOutputDirectory, DisableWinGetMalfunctionDetector, EnableBackupTimestamping, @@ -76,6 +77,7 @@ public enum K DisableProgressNotifications, KillProcessesThatRefuseToDie, ManagerPaths, + GitHubUserLogin, Test1, Test2, @@ -126,7 +128,8 @@ public static string ResolveKey(K key) K.AlreadyWarnedAboutAdmin => "AlreadyWarnedAboutAdmin", K.ShownTelemetryBanner => "ShownTelemetryBanner", K.CollapseNavMenuOnWideScreen => "CollapseNavMenuOnWideScreen", - K.EnablePackageBackup => "EnablePackageBackup", + K.EnablePackageBackup_LOCAL => "EnablePackageBackup", + K.EnablePackageBackup_CLOUD => "EnablePackageBackup_CLOUD", K.ChangeBackupOutputDirectory => "ChangeBackupOutputDirectory", K.DisableWinGetMalfunctionDetector => "DisableWinGetMalfunctionDetector", K.EnableBackupTimestamping => "EnableBackupTimestamping", @@ -163,6 +166,7 @@ public static string ResolveKey(K key) K.DisableProgressNotifications => "DisableProgressNotifications", K.KillProcessesThatRefuseToDie => "KillProcessesThatRefuseToDie", K.ManagerPaths => "ManagerPaths", + K.GitHubUserLogin => "GitHubUserLogin", K.Test1 => "TestSetting1", K.Test2 => "TestSetting2", diff --git a/src/UniGetUI.Interface.Telemetry/TelemetryHandler.cs b/src/UniGetUI.Interface.Telemetry/TelemetryHandler.cs index 8c32accd2e..6d51ad55bd 100644 --- a/src/UniGetUI.Interface.Telemetry/TelemetryHandler.cs +++ b/src/UniGetUI.Interface.Telemetry/TelemetryHandler.cs @@ -41,7 +41,7 @@ public static class TelemetryHandler Settings.K.DisableAutoCheckforUpdates, Settings.K.AutomaticallyUpdatePackages, Settings.K.AskToDeleteNewDesktopShortcuts, - Settings.K.EnablePackageBackup, + Settings.K.EnablePackageBackup_LOCAL, Settings.K.DoCacheAdminRights, Settings.K.DoCacheAdminRightsForBatches, Settings.K.ForceLegacyBundledWinGet, diff --git a/src/UniGetUI/App.xaml.cs b/src/UniGetUI/App.xaml.cs index 60273ff487..8267c836e9 100644 --- a/src/UniGetUI/App.xaml.cs +++ b/src/UniGetUI/App.xaml.cs @@ -404,7 +404,14 @@ public async Task ShowMainWindowFromRedirectAsync(AppActivationArguments rawArgs Logger.Warn("REDIRECTOR ACTIVATOR: args.Kind is not Launch but rather " + kind); } - MainWindow.DispatcherQueue.TryEnqueue(MainWindow.Activate); + /*if (kind == ExtendedActivationKind.Protocol) + { + if (rawArgs.Data is IProtocolActivatedEventArgs protocolArgs) + { + Logger.Info($"Protocol activation received: {protocolArgs.Uri}"); + } + MainWindow.DispatcherQueue.TryEnqueue(MainWindow.Activate); + }*/ } public async void DisposeAndQuit(int outputCode = 0) diff --git a/src/UniGetUI/AppOperationHelper.cs b/src/UniGetUI/AppOperationHelper.cs index 36dad275f4..b657c85440 100644 --- a/src/UniGetUI/AppOperationHelper.cs +++ b/src/UniGetUI/AppOperationHelper.cs @@ -9,7 +9,6 @@ using UniGetUI.Interface.Telemetry; using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Interfaces; -using UniGetUI.PackageEngine.Managers.CargoManager; using UniGetUI.PackageEngine.Managers.PowerShellManager; using UniGetUI.PackageEngine.Operations; using UniGetUI.PackageEngine.PackageClasses; diff --git a/src/UniGetUI/CLIHandler.cs b/src/UniGetUI/CLIHandler.cs index 8961a99ef1..1b67fe0359 100644 --- a/src/UniGetUI/CLIHandler.cs +++ b/src/UniGetUI/CLIHandler.cs @@ -62,7 +62,7 @@ public static int ImportSettings() try { - Settings.ImportFromJSON(file); + Settings.ImportFromFile_JSON(file); } catch (Exception ex) { @@ -87,7 +87,7 @@ public static int ExportSettings() try { - Settings.ExportToJSON(file); + Settings.ExportToFile_JSON(file); } catch (Exception ex) { diff --git a/src/UniGetUI/MainWindow.xaml b/src/UniGetUI/MainWindow.xaml index 7dd86a0b3d..13383a7428 100644 --- a/src/UniGetUI/MainWindow.xaml +++ b/src/UniGetUI/MainWindow.xaml @@ -7,6 +7,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="using:UniGetUI" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:services="using:UniGetUI.Services" xmlns:widgets="using:UniGetUI.Interface.Widgets" xmlns:winex="using:WinUIEx" Title="UniGetUI" @@ -32,11 +33,11 @@ x:Name="TitleBar" Title="UniGetUI" Grid.Row="0" - Margin="0,4" + Margin="0,0,0,-4" + BackRequested="TitleBar_OnBackRequested" + IsBackButtonVisible="False" IsPaneToggleButtonVisible="True" PaneToggleRequested="TitleBar_PaneToggleRequested" - IsBackButtonVisible="False" - BackRequested="TitleBar_OnBackRequested" Visibility="Collapsed"> @@ -44,6 +45,9 @@ + + + + AskForBackupSelection(IEnumerable availableBackups) + { + var dialog = DialogFactory.Create(); + dialog.Title = CoreTools.Translate("Which backup do you want to open?"); + dialog.PrimaryButtonText = CoreTools.Translate("Open"); + dialog.SecondaryButtonText = CoreTools.Translate("Cancel"); + dialog.DefaultButton = ContentDialogButton.Primary; + dialog.IsPrimaryButtonEnabled = false; + + RadioButtons buttons = new RadioButtons(); + foreach(var name in availableBackups) buttons.Items.Add(name); + buttons.SelectionChanged += (_, _) => dialog.IsPrimaryButtonEnabled = true; + + dialog.Content = new StackPanel() + { + Orientation = Orientation.Vertical, + Spacing = 4, + Children = + { + new TextBlock() { + Text = CoreTools.Translate( + "Select the backup you want to open. Later, you will be able to review which packages you want to install."), + TextWrapping = TextWrapping.Wrap + }, + new ScrollViewer() + { + Content = buttons, + HorizontalScrollMode = ScrollMode.Disabled + } + } + }; + + if(await Window.ShowDialogAsync(dialog) is ContentDialogResult.Primary) + return buttons.SelectedItem.ToString() ?? null; + + return null; + } } diff --git a/src/UniGetUI/Pages/DialogPages/InstallOptions_Manager.xaml.cs b/src/UniGetUI/Pages/DialogPages/InstallOptions_Manager.xaml.cs index 0337a4edd7..b662f1138e 100644 --- a/src/UniGetUI/Pages/DialogPages/InstallOptions_Manager.xaml.cs +++ b/src/UniGetUI/Pages/DialogPages/InstallOptions_Manager.xaml.cs @@ -1,7 +1,6 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using UniGetUI.Core.Language; -using UniGetUI.Core.SettingsEngine; using UniGetUI.Core.SettingsEngine.SecureSettings; using UniGetUI.Core.Tools; using UniGetUI.PackageEngine.Enums; diff --git a/src/UniGetUI/Pages/DialogPages/PackageDetailsPage.xaml.cs b/src/UniGetUI/Pages/DialogPages/PackageDetailsPage.xaml.cs index 0382817943..7fa157c21b 100644 --- a/src/UniGetUI/Pages/DialogPages/PackageDetailsPage.xaml.cs +++ b/src/UniGetUI/Pages/DialogPages/PackageDetailsPage.xaml.cs @@ -1,5 +1,4 @@ using System.Collections.ObjectModel; -using System.Text; using Windows.UI; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; diff --git a/src/UniGetUI/Pages/MainView.xaml.cs b/src/UniGetUI/Pages/MainView.xaml.cs index 8d785f11a9..da5edb744e 100644 --- a/src/UniGetUI/Pages/MainView.xaml.cs +++ b/src/UniGetUI/Pages/MainView.xaml.cs @@ -466,12 +466,18 @@ private void MoreNavBtn_Tapped(object sender, Microsoft.UI.Xaml.Input.TappedRout MoreNavButtonMenu.ShowAt(sender as FrameworkElement); } - internal void LoadBundleFile(string param) + internal void LoadBundleFromFile(string param) { NavigateTo(PageType.Bundles); BundlesPage?.OpenFromFile(param); } + internal void LoadBundleFromString(string payload, BundleFormatType format, string source) + { + NavigateTo(PageType.Bundles); + BundlesPage?.OpenFromString(payload, format, source); + } + private void ClearAllFinished_OnClick(object sender, RoutedEventArgs e) { foreach (var widget in MainApp.Operations._operationList.ToArray()) diff --git a/src/UniGetUI/Pages/SettingsPages/GeneralPages/Backup.xaml b/src/UniGetUI/Pages/SettingsPages/GeneralPages/Backup.xaml index d97fe86fb9..9c6d5db8f5 100644 --- a/src/UniGetUI/Pages/SettingsPages/GeneralPages/Backup.xaml +++ b/src/UniGetUI/Pages/SettingsPages/GeneralPages/Backup.xaml @@ -7,6 +7,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="using:UniGetUI.Pages.SettingsPages.GeneralPages" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:services="using:UniGetUI.Services" xmlns:widgets="using:UniGetUI.Interface.Widgets" Background="Transparent" mc:Ignorable="d"> @@ -27,13 +28,7 @@ FontWeight="SemiBold" Text="Package backup" /> - - + @@ -44,29 +39,137 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Click="BackupToGitHubButton_Click" + CornerRadius="8,8,0,0" + Text="Perform a cloud backup now" /> + + + + + + + + + + Text="Local package backup" /> + + + + + + + @@ -80,12 +183,12 @@ + Text="Local backup advanced options" /> @@ -94,13 +197,9 @@ x:Name="EnableBackupTimestampingCheckBox" BorderThickness="1,0,1,1" CornerRadius="0,0,8,8" - IsEnabled="{x:Bind EnablePackageBackupCheckBox._checkbox.IsOn, Mode=OneWay}" + IsEnabled="{x:Bind EnablePackageBackupCheckBox_LOCAL._checkbox.IsOn, Mode=OneWay}" SettingName="EnableBackupTimestamping" Text="Add a timestamp to the backup file names" /> - - - - diff --git a/src/UniGetUI/Pages/SettingsPages/GeneralPages/Backup.xaml.cs b/src/UniGetUI/Pages/SettingsPages/GeneralPages/Backup.xaml.cs index e7babd4480..f43b206fdd 100644 --- a/src/UniGetUI/Pages/SettingsPages/GeneralPages/Backup.xaml.cs +++ b/src/UniGetUI/Pages/SettingsPages/GeneralPages/Backup.xaml.cs @@ -1,11 +1,17 @@ +using System.Data; +using System.Diagnostics; +using System.Security.Authentication; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; -using UniGetUI.Core.Tools; -using UniGetUI.Core.SettingsEngine; +using Microsoft.UI.Xaml.Media.Imaging; using UniGetUI.Core.Data; -using System.Diagnostics; -using UniGetUI.Pages.DialogPages; +using UniGetUI.Core.Logging; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.Core.Tools; using UniGetUI.Interface.SoftwarePages; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.Pages.DialogPages; +using UniGetUI.Services; // To learn more about WinUI, the WinUI project structure, // and more about our project templates, see: http://aka.ms/winui-project-info. @@ -17,25 +23,36 @@ namespace UniGetUI.Pages.SettingsPages.GeneralPages /// public sealed partial class Backup : Page, ISettingsPage { - - + private readonly GitHubAuthService _authService; + private readonly GitHubBackupService _backupService; + private bool _isLoggedIn; + private bool _isLoading; public Backup() { this.InitializeComponent(); - EnablePackageBackupUI(Settings.Get(Settings.K.EnablePackageBackup)); + _authService = new GitHubAuthService(); + _backupService = new GitHubBackupService(_authService); + + EnablePackageBackupUI(Settings.Get(Settings.K.EnablePackageBackup_LOCAL)); ResetBackupDirectory.Content = CoreTools.Translate("Reset"); OpenBackupDirectory.Content = CoreTools.Translate("Open"); + + GitHubAuthService.AuthStatusChanged += (_, _) => _ = UpdateGitHubLoginStatus(); + EnablePackageBackupCheckBox_CLOUD.StateChanged += EnablePackageBackupCheckBox_CLOUD_StateChanged; + _ = UpdateGitHubLoginStatus(); } + + public bool CanGoBack => true; - public string ShortTitle => CoreTools.Translate("Package backup"); + public string ShortTitle => CoreTools.Translate("Backup and Restore"); public event EventHandler? RestartRequired; public event EventHandler? NavigationRequested; - public void ShowRestartBanner(object sender, EventArgs e) + public void ShowRestartBanner(object? sender, EventArgs e) => RestartRequired?.Invoke(this, e); private void ChangeBackupDirectory_Click(object sender, EventArgs e) @@ -55,7 +72,7 @@ public void EnablePackageBackupUI(bool enabled) EnableBackupTimestampingCheckBox.IsEnabled = enabled; ChangeBackupFileNameTextBox.IsEnabled = enabled; ChangeBackupDirectory.IsEnabled = enabled; - BackupNowButton.IsEnabled = enabled; + BackupNowButton_LOCAL.IsEnabled = enabled; if (enabled) { @@ -97,11 +114,176 @@ private void OpenBackupPath_Click(object sender, RoutedEventArgs e) Process.Start("explorer.exe", directory); } - private async void DoBackup_Click(object sender, EventArgs e) + private async void DoBackup_LOCAL_Click(object sender, EventArgs e) { DialogHelper.ShowLoadingDialog(CoreTools.Translate("Performing backup, please wait...")); - await InstalledPackagesPage.BackupPackages(); + await InstalledPackagesPage.BackupPackages_LOCAL(); DialogHelper.HideLoadingDialog(); } + + /* + * + * BEGIN CLOUD BACKUP METHODS + * + */ + private async Task UpdateGitHubLoginStatus() + { + GitHubAuthService authService = new(); + if (authService.IsAuthenticated()) + { + var client = authService.CreateGitHubClient(); + if (client is null) throw new AuthenticationException("How can it be authenticated and fail to create a client?"); + var user = await client.User.Current(); + + _isLoggedIn = true; + LogInButton.Visibility = Visibility.Collapsed; + LogOutButton.Visibility = Visibility.Visible; + GitHubUserTitle.Text = CoreTools.Translate("You are logged in as {0} (@{1})", user.Name, user.Login); + GitHubUserSubtitle.Text = CoreTools.Translate("Nice! Backups will be uploaded to a private gist on your acount"); + GitHubImage.Initials = ""; + GitHubImage.ProfilePicture = new BitmapImage(new Uri(user.AvatarUrl)); + } + else + { + _isLoggedIn = false; + LogInButton.Visibility = Visibility.Visible; + LogOutButton.Visibility = Visibility.Collapsed; + GitHubUserTitle.Text = CoreTools.Translate("Current status: Not logged in"); + GitHubUserSubtitle.Text = CoreTools.Translate("Log in to enable cloud backup"); + GitHubImage.ProfilePicture = null; + } + UpdateCloudControlsEnabled(); + } + + private void UpdateCloudControlsEnabled() + { + LogInButton.IsEnabled = !_isLoading; + LogOutButton.IsEnabled = !_isLoading; + if (_isLoggedIn && !_isLoading) + { + EnablePackageBackupCheckBox_CLOUD.IsEnabled = true; + RestorePackagesFromGitHubButton.IsEnabled = true; + BackupNowButton_Cloud.IsEnabled = Settings.Get(Settings.K.EnablePackageBackup_CLOUD); + } + else + { + EnablePackageBackupCheckBox_CLOUD.IsEnabled = false; + BackupNowButton_Cloud.IsEnabled = false; + RestorePackagesFromGitHubButton.IsEnabled = false; + } + } + + private async void LoginWithGitHubButton_Click(object sender, RoutedEventArgs e) + { + _isLoading = true; + UpdateCloudControlsEnabled(); + + bool success = await _authService.SignInAsync(); + if (!success) + { + DialogHelper.ShowDismissableBalloon( + CoreTools.Translate("Failed"), + CoreTools.Translate("An error occurred while logging in: ") + ); + } + _isLoading = false; + UpdateCloudControlsEnabled(); + } + + private void LogoutGitHubButton_Click(object sender, RoutedEventArgs e) + { + _isLoading = true; + UpdateCloudControlsEnabled(); + + _authService.SignOut(); + + _isLoading = false; + UpdateCloudControlsEnabled(); + } + + private async void RestorePackagesFromGitHubButton_Click(object sender, EventArgs e) + { + RestorePackagesFromGitHubButton.IsEnabled = false; + try + { + DialogHelper.ShowLoadingDialog(CoreTools.Translate("Fetching available backups...")); + var availableBackups = await _backupService.GetAvailableBackups(); + DialogHelper.HideLoadingDialog(); + + var selectedBackup = await DialogHelper.AskForBackupSelection(availableBackups); + if (selectedBackup is null) + { + RestorePackagesFromGitHubButton.IsEnabled = true; + return; + } + selectedBackup = selectedBackup.Split(' ')[0]; + + DialogHelper.ShowLoadingDialog(CoreTools.Translate("Downloading backup...")); + var backupContents = await _backupService.GetBackupContents(selectedBackup); + DialogHelper.HideLoadingDialog(); + await Task.Delay(500); // Prevent race conditions with dialogs + + if (backupContents is null) + throw new DataException($"The backupContents for backup {selectedBackup} returned null"); + + Logger.Info("Successfully loaded package bundle from GitHub Gist."); + DialogHelper.ShowDismissableBalloon( + CoreTools.Translate("Done!"), + CoreTools.Translate("The cloud backup has been loaded successfully.")); + + MainApp.Instance.MainWindow.NavigationPage.LoadBundleFromString( + backupContents, BundleFormatType.UBUNDLE, $"GitHub Gist {selectedBackup}"); + } + catch (Exception ex) + { + Logger.Error("An error occurred while loading a backup:"); + Logger.Error(ex); + + DialogHelper.HideLoadingDialog(); + var errorDialog = DialogHelper.DialogFactory.Create(); + errorDialog.Title = CoreTools.Translate("An error occurred"); + errorDialog.Content = CoreTools.Translate("An error occurred while loading a backup: ") + ex.Message; + errorDialog.PrimaryButtonText = CoreTools.Translate("OK"); + errorDialog.DefaultButton = ContentDialogButton.Primary; + await DialogHelper.Window.ShowDialogAsync(errorDialog); + } + } + + private async void BackupToGitHubButton_Click(object sender, EventArgs e) + { + DialogHelper.ShowLoadingDialog(CoreTools.Translate("Backing up packages to GitHub Gist...")); + + var packagesContent = await InstalledPackagesPage.GenerateBackupContents(); + + try + { + await _backupService.UploadPackageBundle(packagesContent); + DialogHelper.HideLoadingDialog(); + Logger.Info("Successfully backed up settings and packages to GitHub Gist."); + DialogHelper.ShowDismissableBalloon( + CoreTools.Translate("Backup Successful"), + CoreTools.Translate("Your settings and packages have been successfully backed up to GitHub Gist.")); + } + catch (Exception ex) + { + DialogHelper.HideLoadingDialog(); + + Logger.Error("An error occurred while uploading the backup:"); + Logger.Error(ex); + + var dialog = DialogHelper.DialogFactory.Create(); + dialog.Title = CoreTools.Translate("Backup Failed"); + dialog.Content = CoreTools.Translate("Could not back up packages to GitHub Gist: ") + ex.Message; + dialog.PrimaryButtonText = CoreTools.Translate("OK"); + dialog.DefaultButton = ContentDialogButton.Primary; + await DialogHelper.Window.ShowDialogAsync(dialog); + } + } + + private void EnablePackageBackupCheckBox_CLOUD_StateChanged(object? sender, EventArgs e) + { + ShowRestartBanner(sender, e); + UpdateCloudControlsEnabled(); + } } } diff --git a/src/UniGetUI/Pages/SettingsPages/GeneralPages/General.xaml.cs b/src/UniGetUI/Pages/SettingsPages/GeneralPages/General.xaml.cs index 1d482175a8..4e480b7f83 100644 --- a/src/UniGetUI/Pages/SettingsPages/GeneralPages/General.xaml.cs +++ b/src/UniGetUI/Pages/SettingsPages/GeneralPages/General.xaml.cs @@ -67,7 +67,7 @@ private async void ImportSettings(object sender, EventArgs e) if (file != string.Empty) { DialogHelper.ShowLoadingDialog(CoreTools.Translate("Please wait...")); - await Task.Run(() => Settings.ImportFromJSON(file)); + await Task.Run(() => Settings.ImportFromFile_JSON(file)); DialogHelper.HideLoadingDialog(); ShowRestartBanner(this, new()); } @@ -83,7 +83,7 @@ private async void ExportSettings(object sender, EventArgs e) if (file != string.Empty) { DialogHelper.ShowLoadingDialog(CoreTools.Translate("Please wait...")); - await Task.Run(() => Settings.ExportToJSON(file)); + await Task.Run(() => Settings.ExportToFile_JSON(file)); DialogHelper.HideLoadingDialog(); CoreTools.ShowFileOnExplorer(file); } diff --git a/src/UniGetUI/Pages/SettingsPages/GeneralPages/Interface_P.xaml.cs b/src/UniGetUI/Pages/SettingsPages/GeneralPages/Interface_P.xaml.cs index 9597aa8a66..c2117006e9 100644 --- a/src/UniGetUI/Pages/SettingsPages/GeneralPages/Interface_P.xaml.cs +++ b/src/UniGetUI/Pages/SettingsPages/GeneralPages/Interface_P.xaml.cs @@ -3,7 +3,6 @@ using UniGetUI.Core.Tools; using UniGetUI.Core.Data; using UniGetUI.Core.Logging; -using UniGetUI.PackageEngine.PackageClasses; using UniGetUI.Core.SettingsEngine; using System.Diagnostics; diff --git a/src/UniGetUI/Pages/SettingsPages/GeneralPages/Internet.xaml.cs b/src/UniGetUI/Pages/SettingsPages/GeneralPages/Internet.xaml.cs index d8382c4c11..ad8b7d9776 100644 --- a/src/UniGetUI/Pages/SettingsPages/GeneralPages/Internet.xaml.cs +++ b/src/UniGetUI/Pages/SettingsPages/GeneralPages/Internet.xaml.cs @@ -17,9 +17,12 @@ namespace UniGetUI.Pages.SettingsPages.GeneralPages /// public sealed partial class Internet : Page, ISettingsPage { + + public Internet() { this.InitializeComponent(); + UsernameBox.PlaceholderText = CoreTools.Translate("Username"); PasswordBox.PlaceholderText = CoreTools.Translate("Password"); @@ -82,6 +85,7 @@ public Internet() }); } + } public bool CanGoBack => true; diff --git a/src/UniGetUI/Pages/SettingsPages/ManagersPages/PackageManager.xaml.cs b/src/UniGetUI/Pages/SettingsPages/ManagersPages/PackageManager.xaml.cs index 3be4474825..2a264be8af 100644 --- a/src/UniGetUI/Pages/SettingsPages/ManagersPages/PackageManager.xaml.cs +++ b/src/UniGetUI/Pages/SettingsPages/ManagersPages/PackageManager.xaml.cs @@ -21,10 +21,7 @@ using UniGetUI.Core.Data; using UniGetUI.Pages.DialogPages; using UniGetUI.Core.SettingsEngine; -using UniGetUI.PackageEngine.ManagerClasses.Manager; -using UniGetUI.Core.Logging; using UniGetUI.Core.SettingsEngine.SecureSettings; -using UniGetUI.Interface; // To learn more about WinUI, the WinUI project structure, // and more about our project templates, see: http://aka.ms/winui-project-info. diff --git a/src/UniGetUI/Pages/SoftwarePages/InstalledPackagesPage.cs b/src/UniGetUI/Pages/SoftwarePages/InstalledPackagesPage.cs index b07ddd6a7f..3d3872986d 100644 --- a/src/UniGetUI/Pages/SoftwarePages/InstalledPackagesPage.cs +++ b/src/UniGetUI/Pages/SoftwarePages/InstalledPackagesPage.cs @@ -12,6 +12,7 @@ using UniGetUI.PackageEngine.Interfaces; using UniGetUI.PackageEngine.Managers.WingetManager; using UniGetUI.Pages.DialogPages; +using UniGetUI.Services; namespace UniGetUI.Interface.SoftwarePages { @@ -286,9 +287,14 @@ protected override void WhenPackagesLoaded(ReloadReason reason) { if (!HasDoneBackup) { - if (Settings.Get(Settings.K.EnablePackageBackup)) + if (Settings.Get(Settings.K.EnablePackageBackup_LOCAL)) { - _ = BackupPackages(); + _ = BackupPackages_LOCAL(); + } + + if (Settings.Get(Settings.K.EnablePackageBackup_CLOUD)) + { + _ = BackupPackages_CLOUD(); } } @@ -362,20 +368,40 @@ private async void ExportSelection_Click(object sender, RoutedEventArgs e) } - public static async Task BackupPackages() + public static Task GenerateBackupContents() { + Logger.Debug("Starting package backup"); + List packagesToExport = []; + foreach (IPackage package in PEInterface.InstalledPackagesLoader.Packages) + { + packagesToExport.Add(package); + } + return PackageBundlesPage.CreateBundle(packagesToExport.ToArray(), BundleFormatType.UBUNDLE); + } + + public static async Task BackupPackages_CLOUD() + { try { - Logger.Debug("Starting package backup"); - List packagesToExport = []; - foreach (IPackage package in PEInterface.InstalledPackagesLoader.Packages) - { - packagesToExport.Add(package); - } - - string BackupContents = await PackageBundlesPage.CreateBundle(packagesToExport.ToArray(), BundleFormatType.UBUNDLE); + string backupContents = await GenerateBackupContents(); + var authService = new GitHubAuthService(); + var backupService = new GitHubBackupService(authService); + await backupService.UploadPackageBundle(backupContents); + Logger.ImportantInfo("Cloud backup succeeded"); + } + catch (Exception ex) + { + Logger.Error("An error occurred while performing a CLOUD backup"); + Logger.Error(ex); + } + } + public static async Task BackupPackages_LOCAL() + { + try + { + string backupContents = await GenerateBackupContents(); string dirName = Settings.GetValue(Settings.K.ChangeBackupOutputDirectory); if (dirName == "") { @@ -401,13 +427,13 @@ public static async Task BackupPackages() fileName += ".ubundle"; string filePath = Path.Combine(dirName, fileName); - await File.WriteAllTextAsync(filePath, BackupContents); + await File.WriteAllTextAsync(filePath, backupContents); HasDoneBackup = true; Logger.ImportantInfo("Backup saved to " + filePath); } catch (Exception ex) { - Logger.Error("An error occurred while performing a backup"); + Logger.Error("An error occurred while performing a LOCAL backup"); Logger.Error(ex); } } diff --git a/src/UniGetUI/Pages/SoftwarePages/PackageBundlesPage.cs b/src/UniGetUI/Pages/SoftwarePages/PackageBundlesPage.cs index 702e45fb0d..0a12ca998d 100644 --- a/src/UniGetUI/Pages/SoftwarePages/PackageBundlesPage.cs +++ b/src/UniGetUI/Pages/SoftwarePages/PackageBundlesPage.cs @@ -266,11 +266,7 @@ public override void GenerateToolBar() }; HelpButton.Click += (_, _) => { MainApp.Instance.MainWindow.NavigationPage.ShowHelp(); }; - - NewBundle.Click += (s, e) => - { - _ = AskForNewBundle(); - }; + NewBundle.Click += async (s, e) => await AskForNewBundle(); RemoveSelected.Click += (_, _) => { @@ -282,16 +278,8 @@ public override void GenerateToolBar() InstallSkipHash.Click += async (_, _) => await ImportAndInstallPackage(FilteredPackages.GetCheckedPackages(), skiphash: true); InstallInteractive.Click += async (_, _) => await ImportAndInstallPackage(FilteredPackages.GetCheckedPackages(), interactive: true); InstallAsAdmin.Click += async (_, _) => await ImportAndInstallPackage(FilteredPackages.GetCheckedPackages(), elevated: true); - - OpenBundle.Click += async (_, _) => - { - await OpenFromFile(); - }; - - SaveBundle.Click += async (_, _) => - { - await SaveFile(); - }; + OpenBundle.Click += async (_, _) => await AskOpenFromFile(); + SaveBundle.Click += async (_, _) => await SaveFile(); SharePackage.Click += (_, _) => { @@ -449,25 +437,32 @@ private void MenuRemoveFromList_Invoked(object sender, RoutedEventArgs args) Loader.Remove(package); } - public async Task OpenFromFile(string? file = null) + public async Task OpenFromString(string payload, BundleFormatType format, string source) { - try - { - if (await AskForNewBundle() == false) - return; + if (await AskForNewBundle() is false) + return; - if (file is null) - { - // Select file - FileOpenPicker picker = new(MainApp.Instance.MainWindow.GetWindowHandle()); - file = picker.Show(["*.ubundle", "*.json", "*.yaml", "*.xml"]); - if (file == String.Empty) - return; - } + DialogHelper.ShowLoadingDialog(CoreTools.Translate("Loading packages, please wait...")); - DialogHelper.ShowLoadingDialog(CoreTools.Translate("Loading packages, please wait...")); + double open_version = await AddFromBundle(payload, format); + TelemetryHandler.ImportBundle(format); + HasUnsavedChanges = false; + + DialogHelper.HideLoadingDialog(); + if ((int)(open_version*10) != (int)(SerializableBundle.ExpectedVersion*10)) + { // Check only up to first decimal digit, prevent floating point precision error. + Logger.Warn($"The loaded bundle \"{source}\" is based on schema version {open_version}, " + + $"while this UniGetUI build expects version {SerializableBundle.ExpectedVersion}." + + $"\nThis should not be a problem if packages show up, but be careful"); + } - // Read file + } + + public async Task OpenFromFile(string file) + { + try + { + DialogHelper.ShowLoadingDialog(CoreTools.Translate("Loading packages, please wait...")); BundleFormatType formatType; string EXT = file.Split('.')[^1].ToLower(); if (EXT == "yaml") @@ -482,19 +477,8 @@ public async Task OpenFromFile(string? file = null) formatType = BundleFormatType.UBUNDLE; string fileContent = await File.ReadAllTextAsync(file); - - double open_version = await AddFromBundle(fileContent, formatType); - TelemetryHandler.ImportBundle(formatType); - HasUnsavedChanges = false; - DialogHelper.HideLoadingDialog(); - - if ((int)(open_version*10) != (int)(SerializableBundle.ExpectedVersion*10)) - { // Check only up to first decimal digit, prevent floating point precision error. - Logger.Warn($"The loaded bundle \"{file}\" is based on schema version {open_version}, " + - $"while this UniGetUI build expects version {SerializableBundle.ExpectedVersion}." + - $"\nThis should not be a problem if packages show up, but be careful"); - } + await OpenFromString(fileContent, formatType, file); } catch (Exception ex) { @@ -515,6 +499,19 @@ public async Task OpenFromFile(string? file = null) } } + public async Task AskOpenFromFile() + { + if (await AskForNewBundle() is false) + return; + + FileOpenPicker picker = new(MainApp.Instance.MainWindow.GetWindowHandle()); + string file = picker.Show(["*.ubundle", "*.json", "*.yaml", "*.xml"]); + if (file == String.Empty) + return; + + await OpenFromFile(file); + } + public async Task SaveFile() { try diff --git a/src/UniGetUI/Services/GitHubAuthService.cs b/src/UniGetUI/Services/GitHubAuthService.cs new file mode 100644 index 0000000000..06b3a8f229 --- /dev/null +++ b/src/UniGetUI/Services/GitHubAuthService.cs @@ -0,0 +1,226 @@ +using System.Net; +using System.Text; +using Octokit; +using UniGetUI.Core.Data; +using UniGetUI.Core.Logging; +using UniGetUI.Core.SecureSettings; +using UniGetUI.Core.SettingsEngine; +using Windows.System; + +namespace UniGetUI.Services +{ + public class GitHubAuthService + { + private readonly string GitHubClientId = Secrets.GetGitHubClientId(); + private readonly string GitHubClientSecret = Secrets.GetGitHubClientSecret(); + + private const string DataReceivedWebsite = """ + + + +
+ UniGetUI authentication +

Authentication successful

+

You can now close this window and return to UniGetUI

+
+ + """; + + private const string RedirectUri = "http://127.0.0.1:58642/"; + + private readonly GitHubClient _client; + + public static event EventHandler? AuthStatusChanged; + public GitHubAuthService() + { + _client = new GitHubClient(new ProductHeaderValue("UniGetUI", CoreData.VersionName)); + } + + public GitHubClient? CreateGitHubClient() + { + var token = GetAccessToken(); + + if (string.IsNullOrEmpty(token)) + { + Logger.Error("GitHub access token is not available. Cannot perform Gist operation."); + return null; + } + + return new GitHubClient(new ProductHeaderValue("UniGetUI", CoreData.VersionName)) + { + Credentials = new Credentials(token) + }; + } + + public async Task SignInAsync() + { + HttpListener? httpListener = null; + try + { + Logger.Info("Initiating GitHub sign-in process using loopback redirect..."); + + httpListener = new HttpListener(); + httpListener.Prefixes.Add(RedirectUri); + httpListener.Start(); + Logger.Info($"Listening for GitHub callback on {RedirectUri}"); + + var request = new OauthLoginRequest(GitHubClientId) + { + Scopes = { "read:user", "gist" }, + RedirectUri = new Uri(RedirectUri) + }; + + var oauthLoginUrl = _client.Oauth.GetGitHubLoginUrl(request); + + await Launcher.LaunchUriAsync(oauthLoginUrl); + + var context = await httpListener.GetContextAsync(); + + var response = context.Response; + var buffer = Encoding.UTF8.GetBytes(DataReceivedWebsite); + response.ContentLength64 = buffer.Length; + var output = response.OutputStream; + await output.WriteAsync(buffer, 0, buffer.Length); + output.Close(); + + httpListener.Stop(); + Logger.Info("GitHub callback received and processed."); + + var code = context.Request.QueryString["code"]; + if (string.IsNullOrEmpty(code)) + { + var error = context.Request.QueryString["error"]; + var errorDescription = context.Request.QueryString["error_description"]; + Logger.Error($"GitHub OAuth callback returned an error: {error} - {errorDescription}"); + AuthStatusChanged?.Invoke(this, EventArgs.Empty); + return false; + } + + return await _completeSignInAsync(code); + } + catch (HttpListenerException ex) when (ex.ErrorCode == 5) // Access Denied + { + Logger.Error("Access denied to the http listener. Please run the following command in an administrator terminal:"); + Logger.Error($"netsh http add urlacl url={RedirectUri} user=Everyone"); + // Optionally, you could try to run this command for the user. + // For now, just logging the instruction. + AuthStatusChanged?.Invoke(this, EventArgs.Empty); + return false; + } + catch (Exception ex) + { + Logger.Error("Exception during GitHub sign-in process:"); + Logger.Error(ex); + ClearAuthenticatedUserData(); + AuthStatusChanged?.Invoke(this, EventArgs.Empty); + return false; + } + finally + { + httpListener?.Stop(); + } + } + + private async Task _completeSignInAsync(string code) + { + try + { + var tokenRequest = new OauthTokenRequest(GitHubClientId, GitHubClientSecret, code) + { + RedirectUri = new Uri(RedirectUri) // The same redirect_uri must be sent + }; + var token = await _client.Oauth.CreateAccessToken(tokenRequest); + + if (string.IsNullOrEmpty(token.AccessToken)) + { + Logger.Error("Failed to obtain GitHub access token."); + AuthStatusChanged?.Invoke(this, EventArgs.Empty); + return false; + } + + Logger.Info("GitHub login successful. Storing access token."); + SecureGHTokenManager.StoreToken(token.AccessToken); + + var userClient = new GitHubClient(new ProductHeaderValue("UniGetUI")) + { + Credentials = new Credentials(token.AccessToken) + }; + + var user = await userClient.User.Current(); + if (user != null) + { + Settings.SetValue(Settings.K.GitHubUserLogin, user.Login); + Logger.Info($"Logged in as GitHub user: {user.Login}"); + } + else + { + Logger.Warn("Could not retrieve GitHub user information after login."); + } + + AuthStatusChanged?.Invoke(this, EventArgs.Empty); + return true; + } + catch (Exception ex) + { + Logger.Error("Exception during GitHub token exchange:"); + Logger.Error(ex); + ClearAuthenticatedUserData(); + AuthStatusChanged?.Invoke(this, EventArgs.Empty); + return false; + } + } + + public void SignOut() + { + Logger.Info("Signing out from GitHub..."); + try + { + ClearAuthenticatedUserData(); + } + catch (Exception ex) + { + Logger.Error("Failed to log out:"); + Logger.Error(ex); + } + + AuthStatusChanged?.Invoke(this, EventArgs.Empty); + Logger.Info("GitHub sign-out complete."); + } + + private static void ClearAuthenticatedUserData() + { + SecureGHTokenManager.DeleteToken(); + Settings.SetValue(Settings.K.GitHubUserLogin, ""); // Clear stored username + } + + public string? GetAccessToken() + { + return SecureGHTokenManager.GetToken(); + } + + public async Task GetAuthenticatedUserLoginAsync() + { + string? storedLogin = Settings.GetValue(Settings.K.GitHubUserLogin); + await Task.CompletedTask; + return string.IsNullOrEmpty(storedLogin) ? null : storedLogin; + } + + public bool IsAuthenticated() + { + var token = GetAccessToken(); + return !string.IsNullOrEmpty(token); + } + } +} diff --git a/src/UniGetUI/Services/GitHubBackupService.cs b/src/UniGetUI/Services/GitHubBackupService.cs new file mode 100644 index 0000000000..04748d6602 --- /dev/null +++ b/src/UniGetUI/Services/GitHubBackupService.cs @@ -0,0 +1,128 @@ +using Octokit; +using UniGetUI.Core.Logging; +using UniGetUI.Core.Tools; + +namespace UniGetUI.Services +{ + public class GitHubBackupService + { + private const string GistDescription_EndingKey = "#[UNIGETUI_BACKUP_V1]"; + private const string PackageBackup_StartingKey = "#[PACKAGES]"; + + private const string GistDescription = $"UniGetUI package backups - DO NOT RENAME OR MODIFY {GistDescription_EndingKey}"; + private const string ReadMeContents = "" + + "This special Gist is used by UniGetUI to store your package backups. \n" + + "Please DO NOT EDIT the contents or the description of this gist, or unexpected behaviours may occur.\n" + + "Learn more about UniGetUI at https://github.com/marticliment/UniGetUI\n"; + + private readonly GitHubAuthService _authService; + + private readonly string GistFileKey; + + public GitHubBackupService(GitHubAuthService authService) + { + _authService = authService; + string deviceUserUniqueIdentifier = $"{Environment.MachineName}\\{Environment.UserName}".Replace(" ", ""); + GistFileKey = $"{PackageBackup_StartingKey} {deviceUserUniqueIdentifier}"; + } + + /// + /// Assuming authentication is set up, upload the given bundleContents to GitHub + /// + public async Task UploadPackageBundle(string bundleContents) + { + var GHClient = _authService.CreateGitHubClient(); + if (GHClient is null) + throw new Exception("The GitHub user is not authenticated"); + + User user = await GHClient.User.Current(); + + var candidates = await GHClient.Gist.GetAllForUser(user.Login); + Gist? existingBackup = candidates.FirstOrDefault(g => g.Description.EndsWith(GistDescription_EndingKey)); + + if (existingBackup is null) + { + Logger.Warn($"No matching gist was found as a valid backup, a new gist will be created..."); + existingBackup = await _createBackupGistAsync(GHClient); + } + + await _updateBackupGistAsync(GHClient, existingBackup, bundleContents); + Logger.Info($"Cloud backup completed successfully to gist {user.Login}/{existingBackup.Id}"); + } + + /// + /// Upload the given payload to the given gist. + /// Updates the existing file if GistFileKey exists, creates a new one otherwhise. + /// + private async Task _updateBackupGistAsync(GitHubClient client, Gist gist, string payload) + { + var update = new GistUpdate { Description = GistDescription }; + if (update.Files.ContainsKey(GistFileKey)) + { + update.Files[GistFileKey] = new GistFileUpdate { Content = payload }; + } + else + { + update.Files.Add(GistFileKey, new GistFileUpdate { Content = payload }); + } + await client.Gist.Edit(gist.Id, update); + Logger.Info($"Successfully updated Gist ID: {gist.Id}"); + } + + /// + /// Creates a new Gist, prepared to be detectable by UniGetUI, and with the base readme file + /// + private static Task _createBackupGistAsync(GitHubClient client) + { + var newGist = new NewGist + { + Description = GistDescription, + Public = false, + }; + newGist.Files.Add("- UniGetUI Package Backups", ReadMeContents); + return client.Gist.Create(newGist); + } + + /// + /// Retrieves a list of available backups to import + /// + public async Task> GetAvailableBackups() + { + var GHClient = _authService.CreateGitHubClient(); + if (GHClient is null) + throw new Exception("The GitHub user is not authenticated"); + + + User user = await GHClient.User.Current(); + + var candidates = await GHClient.Gist.GetAllForUser(user.Login); + Gist? existingBackup = candidates.FirstOrDefault(g => g.Description.EndsWith(GistDescription_EndingKey)); + + return existingBackup?.Files + .Where(f => f.Key.StartsWith(PackageBackup_StartingKey)) + .Select(f => $"{f.Key.Split(' ')[^1]} ({CoreTools.FormatAsSize(f.Value.Size)})") ?? []; + } + + /// + /// For the given backupName, retrieve the backup contents + /// + public async Task GetBackupContents(string backupName) + { + var GHClient = _authService.CreateGitHubClient(); + if (GHClient is null) + throw new Exception("The GitHub user is not authenticated"); + + User user = await GHClient.User.Current(); + + var candidates = await GHClient.Gist.GetAllForUser(user.Login); + Gist? existingBackup = candidates.FirstOrDefault(g => g.Description.EndsWith(GistDescription_EndingKey)); + if (existingBackup is null) + throw new Exception($"The backup {backupName} was not found"); + + existingBackup = await GHClient.Gist.Get(existingBackup.Id); + return existingBackup.Files + .FirstOrDefault(f => f.Key.StartsWith(PackageBackup_StartingKey) && f.Key.EndsWith(backupName)) + .Value.Content; + } + } +} diff --git a/src/UniGetUI/Services/Secrets.cs b/src/UniGetUI/Services/Secrets.cs new file mode 100644 index 0000000000..fa98eb1859 --- /dev/null +++ b/src/UniGetUI/Services/Secrets.cs @@ -0,0 +1,14 @@ +namespace UniGetUI.Services +{ + internal static partial class Secrets + { + /* ---------------------------------------------------------------- + * W A R N I N G !!! + * + * Seeing errors? Build the project (maybe twice) + */ + public static partial string GetGitHubClientId(); + public static partial string GetGitHubClientSecret(); + /* ------------------------------------------------------------------------ */ + } +} diff --git a/src/UniGetUI/Services/UserAvatar.cs b/src/UniGetUI/Services/UserAvatar.cs new file mode 100644 index 0000000000..9eb4a29f1c --- /dev/null +++ b/src/UniGetUI/Services/UserAvatar.cs @@ -0,0 +1,246 @@ +using Microsoft.UI; +using Microsoft.UI.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using UniGetUI.Core.Logging; +using UniGetUI.Core.Tools; +using UniGetUI.Pages.DialogPages; +using UniGetUI.Pages.SettingsPages.GeneralPages; + +namespace UniGetUI.Services +{ + public partial class PointButton: Button + { + public PointButton() + { + ProtectedCursor = InputSystemCursor.Create(InputSystemCursorShape.Hand); + } + } + + public partial class UserAvatar: UserControl + { + public UserAvatar() + { + VerticalContentAlignment = VerticalAlignment.Center; + HorizontalContentAlignment = HorizontalAlignment.Center; + _ = RefreshStatus(); + GitHubAuthService.AuthStatusChanged += GitHubAuthService_AuthStatusChanged; + } + + private void GitHubAuthService_AuthStatusChanged(object? sender, EventArgs e) + { + _ = RefreshStatus(); + } + + public async Task RefreshStatus() + { + SetLoading(); + var client = new GitHubAuthService(); + // await Task.Delay(1000); + if (client.IsAuthenticated()) + { + Content = await GenerateLogoutControl(); + } + else + { + Content = GenerateLoginControl(); + } + } + + private async void LoginButton_Click(object sender, RoutedEventArgs e) + { + SetLoading(); + try + { + var client = new GitHubAuthService(); + if (client.IsAuthenticated()) + { + Logger.Warn("Login invoked when the client was already logged in!"); + return; + } + + await client.SignInAsync(); + } + catch (Exception ex) + { + DialogHelper.ShowDismissableBalloon( + CoreTools.Translate("Error"), + CoreTools.Translate("Log in failed: ") + ex.Message + ); + } + } + + private void LogoutButton_Click(object sender, RoutedEventArgs e) + { + SetLoading(); + try + { + var client = new GitHubAuthService(); + if (client.IsAuthenticated()) + { + client.SignOut(); + } + } + catch (Exception ex) + { + DialogHelper.ShowDismissableBalloon( + CoreTools.Translate("Error"), + CoreTools.Translate("Log out failed: ") + ex.Message + ); + } + } + + private void SetLoading() + { + this.Content = new ProgressRing() { IsIndeterminate = true, Width = 24, Height = 24 }; + } + + private PointButton GenerateLoginControl() + { + var personPicture = new PersonPicture + { + Width = 36, + Height = 36, + }; + + var translatedTextBlock = new TextBlock + { + Margin = new Thickness(4), + TextWrapping = TextWrapping.WrapWholeWords, + Text = CoreTools.Translate("Log in with GitHub to enable cloud package backup.") + }; + + var hyperlinkButton = new HyperlinkButton + { + Padding = new Thickness(0), + HorizontalAlignment = HorizontalAlignment.Stretch, + Content = CoreTools.Translate("More details"), + NavigateUri = new Uri("https://www.marticliment.com/unigetui/help/cloud-backup-overview/"), + FontSize = 12 + }; + + var loginButton = new PointButton + { + HorizontalAlignment = HorizontalAlignment.Stretch, + Content = CoreTools.Translate("Log in") + }; + loginButton.Click += LoginButton_Click; + + var stackPanel = new StackPanel + { + MaxWidth = 200, + Margin = new Thickness(-8), + Orientation = Orientation.Vertical, + Spacing = 8 + }; + stackPanel.Children.Add(translatedTextBlock); + stackPanel.Children.Add(hyperlinkButton); + stackPanel.Children.Add(loginButton); + + var flyout = new Flyout + { + LightDismissOverlayMode = LightDismissOverlayMode.Off, + Placement = FlyoutPlacementMode.Bottom, + Content = stackPanel + }; + + return new PointButton + { + Margin = new Thickness(0), + Padding = new Thickness(4), + Background = new SolidColorBrush(Colors.Transparent), + BorderThickness = new Thickness(0), + CornerRadius = new CornerRadius(100), + Content = personPicture, + Flyout = flyout + }; + } + + private async Task GenerateLogoutControl() + { + var authClient = new GitHubAuthService(); + var GHClient = authClient.CreateGitHubClient(); + if(GHClient is null) + { + Logger.Error("Client did not report valid authentication"); + return GenerateLoginControl(); + } + + var user = await GHClient.User.Current(); + + var personPicture = new PersonPicture + { + Width = 36, + Height = 36, + ProfilePicture = new BitmapImage(new Uri(user.AvatarUrl)) + }; + + var text1 = new TextBlock + { + Margin = new Thickness(4), + TextWrapping = TextWrapping.WrapWholeWords, + Text = CoreTools.Translate("You are logged in as {0} (@{1})", user.Name, user.Login) + }; + + var text2 = new TextBlock + { + Margin = new Thickness(4), + TextWrapping = TextWrapping.WrapWholeWords, + FontSize = 12, + FontWeight = new(500), + Text = CoreTools.Translate("If you have cloud backup enabled, it will be saved as a GitHub Gist on this account") + }; + + var hyperlinkButton = new HyperlinkButton + { + Padding = new Thickness(0), + HorizontalAlignment = HorizontalAlignment.Stretch, + Content = "Backup settings", + FontSize = 12 + }; + hyperlinkButton.Click += (_, _) => MainApp.Instance.MainWindow.NavigationPage.OpenSettingsPage(typeof(Backup)); + + var loginButton = new PointButton + { + HorizontalAlignment = HorizontalAlignment.Stretch, + Content = "Log out", + Background = new SolidColorBrush(ActualTheme is ElementTheme.Dark? Colors.DarkRed: Colors.PaleVioletRed), + BorderThickness = new(0) + }; + loginButton.Click += LogoutButton_Click; + + var stackPanel = new StackPanel + { + MaxWidth = 200, + Margin = new Thickness(-8), + Orientation = Orientation.Vertical, + Spacing = 8 + }; + stackPanel.Children.Add(text1); + stackPanel.Children.Add(text2); + stackPanel.Children.Add(hyperlinkButton); + stackPanel.Children.Add(loginButton); + + var flyout = new Flyout + { + LightDismissOverlayMode = LightDismissOverlayMode.Off, + Placement = FlyoutPlacementMode.Bottom, + Content = stackPanel + }; + + return new PointButton + { + Margin = new Thickness(0), + Padding = new Thickness(4), + Background = new SolidColorBrush(Colors.Transparent), + BorderThickness = new Thickness(0), + CornerRadius = new CornerRadius(100), + Content = personPicture, + Flyout = flyout + }; + } + } +} diff --git a/src/UniGetUI/Services/generate-secrets.ps1 b/src/UniGetUI/Services/generate-secrets.ps1 new file mode 100644 index 0000000000..69da177ac5 --- /dev/null +++ b/src/UniGetUI/Services/generate-secrets.ps1 @@ -0,0 +1,32 @@ +param ( + [string]$OutputPath = "obj\Generated" +) + +# Ensure directory exists +if (-not (Test-Path -Path "$OutputPath\Generated Files")) { + New-Item -ItemType Directory -Path "$OutputPath\Generated Files" -Force | Out-Null +} + +if (-not (Test-Path -Path "Generated Files")) { + New-Item -ItemType Directory -Path "Generated Files" -Force | Out-Null +} + + +$clientId = $env:GH_UGUI_CLIENT_ID +$clientSecret = $env:GH_UGUI_CLIENT_SECRET + +if (-not $clientId) { $clientId = "CLIENT_ID_UNSET" } +if (-not $clientSecret) { $clientSecret = "CLIENT_SECRET_UNSET" } + +@" +// Auto-generated file - do not modidy +namespace UniGetUI.Services +{ + internal static partial class Secrets + { + public static partial string GetGitHubClientId() => `"$clientId`"; + public static partial string GetGitHubClientSecret() => `"$clientSecret`"; + } +} +"@ | Set-Content -Encoding UTF8 "Generated Files\Secrets.Generated.cs" +cp "Generated Files\Secrets.Generated.cs" "$OutputPath\Generated Files\Secrets.Generated.cs" diff --git a/src/UniGetUI/UniGetUI.csproj b/src/UniGetUI/UniGetUI.csproj index 1d59d2b18e..abc7a6c4e9 100644 --- a/src/UniGetUI/UniGetUI.csproj +++ b/src/UniGetUI/UniGetUI.csproj @@ -18,11 +18,28 @@ true partial true + + + + + + $(IntermediateOutputPath)\Generated Files\Secrets.Generated.cs + + + + + + + - - - + + + + + + @@ -87,6 +104,8 @@ + + @@ -327,4 +346,8 @@ + + + +