From 38d37341c13b2bdeae16b53506872bfdb1a786d9 Mon Sep 17 00:00:00 2001 From: Symb0x76 <9667434@qq.com> Date: Thu, 12 Feb 2026 12:01:03 +0800 Subject: [PATCH 1/3] feat: Add GitHub CLI package manager integration - Introduced GitHubCli manager to handle package operations for GitHub repositories. - Implemented functionality to track repositories, find packages, and manage installations. - Added helper classes for package details and operations specific to GitHub CLI. - Updated project references and solution structure to include the new GitHub CLI manager. - Modified settings to accommodate tracked repositories for GitHub CLI. - Updated application version in the manifest. --- .gitignore | 1 + UniGetUI.iss | 6 +- scripts/BuildNumber | 2 +- src/SharedAssemblyInfo.cs | 6 +- src/UniGetUI.Core.Data/CoreData.cs | 4 +- .../IconCacheEngine.cs | 26 +- .../SettingsEngine_Names.cs | 2 + .../GitHubCli.cs | 391 ++++++++++++++++++ .../Helpers/GitHubCliPkgDetailsHelper.cs | 252 +++++++++++ .../Helpers/GitHubCliPkgOperationHelper.cs | 303 ++++++++++++++ ...UI.PackageEngine.Managers.GitHubCli.csproj | 22 + .../PEInterface.cs | 4 +- .../UniGetUI.PackageEngine.PEInterface.csproj | 1 + src/UniGetUI.sln | 7 + .../ManagersPages/PackageManager.xaml.cs | 2 + src/UniGetUI/app.manifest | 2 +- ...aries.WindowsPackageManager.Interop.csproj | 2 +- 17 files changed, 1019 insertions(+), 14 deletions(-) create mode 100644 src/UniGetUI.PackageEngine.Managers.GitHubCli/GitHubCli.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.GitHubCli/Helpers/GitHubCliPkgDetailsHelper.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.GitHubCli/Helpers/GitHubCliPkgOperationHelper.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.GitHubCli/UniGetUI.PackageEngine.Managers.GitHubCli.csproj diff --git a/.gitignore b/.gitignore index d128df2c9c..71a8f87205 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /.vscode/ src/.vscode/ +*.code-workspace "UniGetUI Store.exe" UniGetUI Store Installer.exe UniGetUI Installer.exe diff --git a/UniGetUI.iss b/UniGetUI.iss index 07ce8ee53c..9c9ceffbf5 100644 --- a/UniGetUI.iss +++ b/UniGetUI.iss @@ -1,7 +1,7 @@ ; Script generated by the Inno Setup Script Wizard. ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! -#define MyAppVersion "3.3.7" +#define MyAppVersion "ghTest" #define MyAppName "UniGetUI" #define MyAppPublisher "Martí Climent" #define MyAppURL "https://github.com/marticliment/UniGetUI" @@ -14,7 +14,7 @@ [Setup] ; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. ; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) -UninstallDisplayName="UniGetUI" +UninstallDisplayName="UniGetUI (PreRelease)" AppId={{889610CC-4337-4BDB-AC3B-4F21806C0BDE} AppName={#MyAppName} AppVersion={#MyAppVersion} @@ -23,7 +23,7 @@ AppPublisher={#MyAppPublisher} AppPublisherURL="https://www.marticliment.com/unigetui/" AppSupportURL={#MyAppURL} AppUpdatesURL={#MyAppURL} -VersionInfoVersion=3.3.7.0 +VersionInfoVersion=1.1.1.0 DefaultDirName="{autopf64}\UniGetUI" DisableProgramGroupPage=yes DisableDirPage=no diff --git a/scripts/BuildNumber b/scripts/BuildNumber index 3fbd193e4a..9d07aa0df5 100644 --- a/scripts/BuildNumber +++ b/scripts/BuildNumber @@ -1 +1 @@ -106 \ No newline at end of file +111 \ No newline at end of file diff --git a/src/SharedAssemblyInfo.cs b/src/SharedAssemblyInfo.cs index 88dc064b32..ae1ff443b6 100644 --- a/src/SharedAssemblyInfo.cs +++ b/src/SharedAssemblyInfo.cs @@ -6,7 +6,7 @@ [assembly: AssemblyTitle("UniGetUI")] [assembly: AssemblyDefaultAlias("UniGetUI")] [assembly: AssemblyCopyright("2025, Martí Climent")] -[assembly: AssemblyVersion("3.3.7.0")] -[assembly: AssemblyFileVersion("3.3.7.0")] -[assembly: AssemblyInformationalVersion("3.3.7")] +[assembly: AssemblyVersion("1.1.1.0")] +[assembly: AssemblyFileVersion("1.1.1.0")] +[assembly: AssemblyInformationalVersion("ghTest")] [assembly: SupportedOSPlatform("windows10.0.19041")] diff --git a/src/UniGetUI.Core.Data/CoreData.cs b/src/UniGetUI.Core.Data/CoreData.cs index e20fd01e45..5688e0a72c 100644 --- a/src/UniGetUI.Core.Data/CoreData.cs +++ b/src/UniGetUI.Core.Data/CoreData.cs @@ -7,8 +7,8 @@ public static class CoreData { private static int? __code_page; public static int CODE_PAGE { get => __code_page ??= GetCodePage(); } - public const string VersionName = "3.3.7"; // Do not modify this line, use file scripts/apply_versions.py - public const int BuildNumber = 106; // Do not modify this line, use file scripts/apply_versions.py + public const string VersionName = "ghTest"; // Do not modify this line, use file scripts/apply_versions.py + public const int BuildNumber = 111; // Do not modify this line, use file scripts/apply_versions.py public const string UserAgentString = $"UniGetUI/{VersionName} (https://marticliment.com/unigetui/; contact@marticliment.com)"; diff --git a/src/UniGetUI.Core.IconStore/IconCacheEngine.cs b/src/UniGetUI.Core.IconStore/IconCacheEngine.cs index 3444e2754f..1effbd32cc 100644 --- a/src/UniGetUI.Core.IconStore/IconCacheEngine.cs +++ b/src/UniGetUI.Core.IconStore/IconCacheEngine.cs @@ -187,14 +187,20 @@ cachedIconFile is not null && using HttpClient client = new(CoreTools.GenericHttpClientParameters); client.DefaultRequestHeaders.UserAgent.ParseAdd(CoreData.UserAgentString); - HttpResponseMessage response = client.GetAsync(icon.Url).GetAwaiter().GetResult(); + using HttpResponseMessage response = client.GetAsync(icon.Url, HttpCompletionOption.ResponseHeadersRead).GetAwaiter().GetResult(); if (!response.IsSuccessStatusCode) { Logger.Warn($"Icon download attempt at {icon.Url} failed with code {response.StatusCode}"); return null; } - string mimeType = response.Content.Headers.GetValues("Content-Type").First(); + string? mimeType = response.Content.Headers.ContentType?.MediaType; + if (string.IsNullOrWhiteSpace(mimeType)) + { + Logger.Warn($"No Content-Type was returned for icon {icon.Url}, aborting download"); + return null; + } + if (!MimeToExtension.TryGetValue(mimeType, out string? extension)) { Logger.Warn($"Unknown mimetype {mimeType} for icon {icon.Url}, aborting download"); @@ -243,6 +249,22 @@ cachedIconFile is not null && DeteteCachedFiles(iconLocation); return null; } + catch (HttpRequestException ex) + { + string socketData = ""; + if (ex.InnerException is IOException ioEx && ioEx.InnerException is System.Net.Sockets.SocketException socketEx) + { + socketData = $" [SocketErrorCode={socketEx.SocketErrorCode}, NativeError={socketEx.NativeErrorCode}]"; + } + + Logger.Warn($"Failed to download icon from {icon.Url}: {ex.Message}{socketData}"); + return null; + } + catch (IOException ex) + { + Logger.Warn($"I/O error while saving icon from {icon.Url}: {ex.Message}"); + return null; + } catch (Exception ex) { Logger.Error(ex); diff --git a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs index 52a8a0501e..5c200e28df 100644 --- a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs +++ b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs @@ -79,6 +79,7 @@ public enum K KillProcessesThatRefuseToDie, ManagerPaths, GitHubUserLogin, + GitHubCliTrackedRepositories, DisableNewProcessLineHandler, InstallInstalledPackagesBundlesPage, ProhibitElevation, @@ -175,6 +176,7 @@ public static string ResolveKey(K key) K.KillProcessesThatRefuseToDie => "KillProcessesThatRefuseToDie", K.ManagerPaths => "ManagerPaths", K.GitHubUserLogin => "GitHubUserLogin", + K.GitHubCliTrackedRepositories => "GitHubCliTrackedRepositories", K.DisableNewProcessLineHandler => "DisableNewProcessLineHandler", K.InstallInstalledPackagesBundlesPage => "InstallInstalledPackagesBundlesPage", K.ProhibitElevation => "ProhibitElevation", diff --git a/src/UniGetUI.PackageEngine.Managers.GitHubCli/GitHubCli.cs b/src/UniGetUI.PackageEngine.Managers.GitHubCli/GitHubCli.cs new file mode 100644 index 0000000000..70622e0c55 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.GitHubCli/GitHubCli.cs @@ -0,0 +1,391 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using UniGetUI.Core.Data; +using UniGetUI.Core.Logging; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.Core.Tools; +using UniGetUI.Interface.Enums; +using UniGetUI.PackageEngine.Classes.Manager; +using UniGetUI.PackageEngine.Classes.Manager.Classes; +using UniGetUI.PackageEngine.Classes.Manager.ManagerHelpers; +using UniGetUI.PackageEngine.ManagerClasses.Classes; +using UniGetUI.PackageEngine.ManagerClasses.Manager; +using UniGetUI.PackageEngine.PackageClasses; + +namespace UniGetUI.PackageEngine.Managers.GitHubCliManager; + +public partial class GitHubCli : PackageManager +{ + private const string UnknownVersion = "Unknown"; + private static readonly Uri GitHubUrl = new("https://github.com/"); + private readonly ConcurrentDictionary _hasReleasesCache = new(StringComparer.OrdinalIgnoreCase); + + [GeneratedRegex("^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")] + private static partial Regex RepositoryIdRegex(); + + public GitHubCli() + { + Dependencies = + [ + new ManagerDependency( + "GitHub CLI", + CoreData.PowerShell5, + "-ExecutionPolicy Bypass -NoLogo -NoProfile -Command \"& {winget install --id GitHub.cli --exact --source winget --accept-source-agreements --accept-package-agreements --force; if($error.count -ne 0){pause}}\"", + "winget install --id GitHub.cli --exact --source winget", + async () => (await CoreTools.WhichAsync("gh.exe")).Item1) + ]; + + Capabilities = new ManagerCapabilities + { + CanDownloadInstaller = true, + CanRunAsAdmin = true, + CanRunInteractively = true, + SupportsCustomPackageIcons = true, + SupportsProxy = ProxySupport.No, + SupportsProxyAuth = false + }; + + var source = new ManagerSource(this, "GitHub", GitHubUrl); + Properties = new ManagerProperties + { + Name = "GitHubCLI", + DisplayName = "GitHub CLI", + Description = CoreTools.Translate("GitHub's command-line tool. Search repositories and automatically download the latest release assets."), + IconId = IconType.Download, + ColorIconId = "github", + ExecutableFriendlyName = "gh.exe", + InstallVerb = "release", + UninstallVerb = "--version", + UpdateVerb = "release", + DefaultSource = source, + KnownSources = [source] + }; + + DetailsHelper = new GitHubCliPkgDetailsHelper(this); + OperationHelper = new GitHubCliPkgOperationHelper(this); + } + + protected override IReadOnlyList FindPackages_UnSafe(string query) + { + var safeQuery = CoreTools.EnsureSafeQueryString(query); + if (string.IsNullOrWhiteSpace(safeQuery)) + return []; + + JsonNode? node = RunJsonCommand( + $"search repos \"{safeQuery}\" --limit 50 --json nameWithOwner", + Enums.LoggableTaskType.FindPackages); + + JsonArray? repos = node as JsonArray; + if (repos is null) + { + repos = SearchRepositoriesViaApi(safeQuery); + if (repos is null) + return []; + } + + List packages = []; + foreach (JsonNode? repoNode in repos) + { + string? repositoryId = repoNode?["nameWithOwner"]?.ToString() + ?? repoNode?["full_name"]?.ToString(); + if (!IsValidRepositoryId(repositoryId)) + continue; + + if (!HasReleases(repositoryId!, Enums.LoggableTaskType.FindPackages)) + continue; + + packages.Add(new Package(repositoryId!, repositoryId!, UnknownVersion, DefaultSource, this)); + } + return packages; + } + + private JsonArray? SearchRepositoriesViaApi(string safeQuery) + { + JsonNode? node = RunJsonCommand( + $"api search/repositories -f q=\"{safeQuery}\" -f per_page=50", + Enums.LoggableTaskType.FindPackages); + + if (node is not JsonObject root || root["items"] is not JsonArray items) + return null; + + return items; + } + + protected override IReadOnlyList GetAvailableUpdates_UnSafe() + { + Dictionary trackedRepositories = GetTrackedRepositories(); + HashSet repositoryIds = [.. trackedRepositories.Keys]; + + foreach (string watched in GetWatchedRepositories()) + repositoryIds.Add(watched); + + List updates = []; + bool trackedRepositoriesChanged = false; + + foreach (string repositoryId in repositoryIds) + { + string currentVersion = trackedRepositories.GetValueOrDefault(repositoryId, ""); + string? latestVersion = GetLatestReleaseTag(repositoryId, Enums.LoggableTaskType.ListUpdates); + if (string.IsNullOrWhiteSpace(latestVersion)) + continue; + + if (string.IsNullOrWhiteSpace(currentVersion) || currentVersion == UnknownVersion) + { + trackedRepositories[repositoryId] = latestVersion; + trackedRepositoriesChanged = true; + continue; + } + + if (latestVersion != currentVersion) + { + updates.Add(new Package(repositoryId, repositoryId, currentVersion, latestVersion, DefaultSource, this)); + } + } + + if (trackedRepositoriesChanged) + SaveTrackedRepositories(trackedRepositories); + + return updates; + } + + protected override IReadOnlyList GetInstalledPackages_UnSafe() + { + Dictionary trackedRepositories = GetTrackedRepositories(); + HashSet repositoryIds = [.. trackedRepositories.Keys]; + + foreach (string watched in GetWatchedRepositories()) + repositoryIds.Add(watched); + + bool trackedRepositoriesChanged = false; + List installedPackages = []; + foreach (string repositoryId in repositoryIds) + { + string currentVersion = trackedRepositories.GetValueOrDefault(repositoryId, ""); + if (string.IsNullOrWhiteSpace(currentVersion)) + { + currentVersion = GetLatestReleaseTag(repositoryId, Enums.LoggableTaskType.ListInstalledPackages) ?? UnknownVersion; + if (currentVersion != UnknownVersion) + { + trackedRepositories[repositoryId] = currentVersion; + trackedRepositoriesChanged = true; + } + } + installedPackages.Add(new Package(repositoryId, repositoryId, currentVersion, DefaultSource, this)); + } + + if (trackedRepositoriesChanged) + SaveTrackedRepositories(trackedRepositories); + + return installedPackages; + } + + public override IReadOnlyList FindCandidateExecutableFiles() + { + var candidates = CoreTools.WhichMultiple("gh.exe"); + foreach (string candidate in CoreTools.WhichMultiple("gh")) + { + if (!candidates.Contains(candidate, StringComparer.OrdinalIgnoreCase)) + candidates.Add(candidate); + } + return candidates; + } + + protected override void _loadManagerExecutableFile(out bool found, out string path, out string callArguments) + { + var (_found, executablePath) = GetExecutableFile(); + found = _found; + path = executablePath; + callArguments = ""; + } + + protected override void _loadManagerVersion(out string version) + { + using Process process = GetProcess("--version"); + process.Start(); + version = process.StandardOutput.ReadLine()?.Trim() ?? ""; + string error = process.StandardError.ReadToEnd(); + if (!string.IsNullOrWhiteSpace(error)) + Logger.Warn($"gh --version stderr: {error.Trim()}"); + process.WaitForExit(); + } + + internal static bool IsValidRepositoryId(string? repositoryId) + => !string.IsNullOrWhiteSpace(repositoryId) && RepositoryIdRegex().IsMatch(repositoryId); + + private Process GetProcess(string extraArguments) + { + return new Process + { + StartInfo = new ProcessStartInfo + { + FileName = Status.ExecutablePath, + Arguments = $"{Status.ExecutableCallArgs} {extraArguments}".Trim(), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8 + } + }; + } + + internal JsonNode? RunJsonCommand(string extraArguments, Enums.LoggableTaskType taskType) + { + using Process process = GetProcess(extraArguments); + IProcessTaskLogger logger = TaskLogger.CreateNew(taskType, process); + process.Start(); + + string output = process.StandardOutput.ReadToEnd(); + string error = process.StandardError.ReadToEnd(); + + if (!string.IsNullOrWhiteSpace(output)) + logger.AddToStdOut(output); + if (!string.IsNullOrWhiteSpace(error)) + logger.AddToStdErr(error); + + process.WaitForExit(); + logger.Close(process.ExitCode); + + if (process.ExitCode != 0 || string.IsNullOrWhiteSpace(output)) + return null; + + try + { + return JsonNode.Parse(output); + } + catch (Exception ex) + { + Logger.Error($"Failed to parse JSON output for manager {Name} and args \"{extraArguments}\""); + Logger.Error(ex); + return null; + } + } + + internal JsonObject? GetRepositoryInfo(string repositoryId, Enums.LoggableTaskType taskType) + { + if (!IsValidRepositoryId(repositoryId)) + return null; + + return RunJsonCommand($"api repos/{repositoryId}", taskType) as JsonObject; + } + + internal JsonObject? GetLatestReleaseInfo(string repositoryId, Enums.LoggableTaskType taskType) + { + if (!IsValidRepositoryId(repositoryId)) + return null; + + return RunJsonCommand($"api repos/{repositoryId}/releases/latest", taskType) as JsonObject; + } + + internal string? GetLatestReleaseTag(string repositoryId, Enums.LoggableTaskType taskType) + { + JsonObject? release = GetLatestReleaseInfo(repositoryId, taskType); + return release?["tag_name"]?.ToString(); + } + + internal bool HasReleases(string repositoryId, Enums.LoggableTaskType taskType) + { + if (!IsValidRepositoryId(repositoryId)) + return false; + + if (_hasReleasesCache.TryGetValue(repositoryId, out bool hasReleases)) + return hasReleases; + + JsonNode? node = RunJsonCommand($"api repos/{repositoryId}/releases?per_page=1", taskType); + if (node is not JsonArray releases) + return false; + + hasReleases = releases.Count > 0; + _hasReleasesCache[repositoryId] = hasReleases; + return hasReleases; + } + + internal static string GetDownloadDirectory(string repositoryId) + { + string safeRepositoryId = CoreTools.MakeValidFileName(repositoryId.Replace('/', '_')); + return Path.Join( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Downloads", + "UniGetUI", + "GitHub Releases", + safeRepositoryId); + } + + internal static string GetDefaultDownloadDirectory() + { + return Path.Join( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Downloads"); + } + + private IReadOnlyList GetWatchedRepositories() + { + JsonNode? node = RunJsonCommand( + "api --paginate --slurp /user/subscriptions", + Enums.LoggableTaskType.ListInstalledPackages); + + if (node is not JsonArray pages) + return []; + + HashSet repositories = []; + foreach (JsonNode? pageNode in pages) + { + if (pageNode is not JsonArray repoArray) + continue; + + foreach (JsonNode? repoNode in repoArray) + { + string? fullName = repoNode?["full_name"]?.ToString(); + if (IsValidRepositoryId(fullName)) + repositories.Add(fullName!); + } + } + + return [.. repositories]; + } + + internal static Dictionary GetTrackedRepositories() + { + Dictionary repositories = []; + IReadOnlyDictionary rawRepositories = + Settings.GetDictionary(Settings.K.GitHubCliTrackedRepositories); + + foreach ((string repositoryId, string? version) in rawRepositories) + { + if (!IsValidRepositoryId(repositoryId)) + continue; + + repositories[repositoryId] = version?.Trim() ?? ""; + } + + return repositories; + } + + internal static void SaveTrackedRepositories(Dictionary repositories) + { + Settings.SetDictionary(Settings.K.GitHubCliTrackedRepositories, repositories); + } + + internal static void TrackRepository(string repositoryId, string version) + { + if (!IsValidRepositoryId(repositoryId)) + return; + + Dictionary repositories = GetTrackedRepositories(); + repositories[repositoryId] = version; + SaveTrackedRepositories(repositories); + } + + internal static bool RemoveTrackedRepository(string repositoryId) + { + Dictionary repositories = GetTrackedRepositories(); + bool removed = repositories.Remove(repositoryId); + if (removed) + SaveTrackedRepositories(repositories); + return removed; + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.GitHubCli/Helpers/GitHubCliPkgDetailsHelper.cs b/src/UniGetUI.PackageEngine.Managers.GitHubCli/Helpers/GitHubCliPkgDetailsHelper.cs new file mode 100644 index 0000000000..331f49a354 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.GitHubCli/Helpers/GitHubCliPkgDetailsHelper.cs @@ -0,0 +1,252 @@ +using System.Text.Json.Nodes; +using System.Runtime.InteropServices; +using UniGetUI.Core.IconEngine; +using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine.Classes.Manager.BaseProviders; +using UniGetUI.PackageEngine.Interfaces; + +namespace UniGetUI.PackageEngine.Managers.GitHubCliManager; + +internal sealed class GitHubCliPkgDetailsHelper : BasePkgDetailsHelper +{ + private readonly GitHubCli _manager; + internal static readonly string[] PreferredExtensions = + [ + ".exe", + ".msi", + ".msixbundle", + ".msix", + ".appx", + ".zip" + ]; + internal static readonly string[] AutoInstallableExtensions = + [ + ".exe", + ".msi", + ".msixbundle", + ".msix", + ".appx" + ]; + + public GitHubCliPkgDetailsHelper(GitHubCli manager) : base(manager) + { + _manager = manager; + } + + protected override void GetDetails_UnSafe(IPackageDetails details) + { + string repositoryId = details.Package.Id; + if (!GitHubCli.IsValidRepositoryId(repositoryId)) + throw new InvalidDataException($"Repository id \"{repositoryId}\" is not valid"); + + JsonObject? repository = _manager.GetRepositoryInfo(repositoryId, Enums.LoggableTaskType.LoadPackageDetails); + JsonObject? release = _manager.GetLatestReleaseInfo(repositoryId, Enums.LoggableTaskType.LoadPackageDetails); + + details.ManifestUrl = new Uri($"https://github.com/{repositoryId}/releases"); + + if (repository is not null) + PopulateRepositoryDetails(repository, details); + + if (release is not null) + PopulateReleaseDetails(release, details); + + if (details.InstallerUrl is not null) + details.InstallerSize = CoreTools.GetFileSizeAsLong(details.InstallerUrl); + } + + private static void PopulateRepositoryDetails(JsonObject repository, IPackageDetails details) + { + details.Description = repository["description"]?.ToString(); + + string? owner = repository["owner"]?["login"]?.ToString(); + if (!string.IsNullOrWhiteSpace(owner)) + { + details.Author = owner; + details.Publisher = owner; + } + + if (Uri.TryCreate(repository["html_url"]?.ToString(), UriKind.Absolute, out var homepageUrl)) + details.HomepageUrl = homepageUrl; + + details.License = repository["license"]?["spdx_id"]?.ToString(); + if (Uri.TryCreate(repository["license"]?["url"]?.ToString(), UriKind.Absolute, out var licenseUrl)) + details.LicenseUrl = licenseUrl; + + if (repository["topics"] is JsonArray topics) + { + details.Tags = topics + .Where(topic => !string.IsNullOrWhiteSpace(topic?.ToString())) + .Select(topic => topic!.ToString()) + .ToArray(); + } + } + + private static void PopulateReleaseDetails(JsonObject release, IPackageDetails details) + { + details.UpdateDate = release["published_at"]?.ToString(); + details.ReleaseNotes = release["body"]?.ToString(); + if (Uri.TryCreate(release["html_url"]?.ToString(), UriKind.Absolute, out var releaseUrl)) + details.ReleaseNotesUrl = releaseUrl; + + var installerUrl = SelectInstallerUrl(release, out string? installerType); + if (installerUrl is not null) + { + details.InstallerUrl = installerUrl; + details.InstallerType = installerType; + } + else if (Uri.TryCreate(release["zipball_url"]?.ToString(), UriKind.Absolute, out var zipballUrl)) + { + details.InstallerUrl = zipballUrl; + details.InstallerType = "ZIP"; + } + } + + private static Uri? SelectInstallerUrl(JsonObject release, out string? installerType) + { + installerType = null; + JsonObject? selected = SelectBestAssetFromRelease(release); + if (selected is null) + return null; + + string? downloadUrl = selected["browser_download_url"]?.ToString(); + if (!Uri.TryCreate(downloadUrl, UriKind.Absolute, out var installerUrl)) + return null; + + string fileName = selected["name"]?.ToString() ?? ""; + string extension = Path.GetExtension(fileName).Trim('.'); + installerType = string.IsNullOrWhiteSpace(extension) + ? "GitHub release asset" + : extension.ToUpperInvariant(); + + return installerUrl; + } + + internal static JsonObject? SelectBestAssetFromRelease( + JsonObject release, + bool autoInstallableOnly = false) + { + if (release["assets"] is not JsonArray assets || assets.Count == 0) + return null; + + var candidates = assets.OfType().ToList(); + if (autoInstallableOnly) + { + candidates = candidates.Where(asset => + { + string extension = Path.GetExtension(asset["name"]?.ToString() ?? ""); + return AutoInstallableExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase); + }).ToList(); + } + + return SelectBestAsset( + candidates, + autoInstallableOnly ? AutoInstallableExtensions : PreferredExtensions); + } + + private static JsonObject? SelectBestAsset( + IReadOnlyList candidates, + IReadOnlyList preferredExtensions) + { + if (!candidates.Any()) + return null; + + var rankedAssets = candidates + .Select((asset, index) => + { + string name = asset["name"]?.ToString() ?? ""; + int score = ComputeAssetScore(name, preferredExtensions); + return new + { + Asset = asset, + Score = score, + Index = index + }; + }) + .OrderByDescending(item => item.Score) + .ThenBy(item => item.Index) + .ToList(); + + return rankedAssets.FirstOrDefault()?.Asset; + } + + private static int ComputeAssetScore(string assetName, IReadOnlyList preferredExtensions) + { + string name = assetName.ToLowerInvariant(); + int score = 0; + + int extensionPriority = GetExtensionPriority(name, preferredExtensions); + score += extensionPriority >= 0 ? (300 - extensionPriority * 40) : -200; + + bool hasWindowsTag = ContainsAny(name, ["windows", "win32", "win64", "-win-", "_win_", ".win."]); + bool hasLinuxTag = ContainsAny(name, ["linux", "ubuntu", "debian", "fedora", "rpm", "appimage", "musl"]); + bool hasMacTag = ContainsAny(name, ["darwin", "macos", "osx"]); + + if (hasWindowsTag) + score += 120; + if (hasLinuxTag) + score -= 220; + if (hasMacTag) + score -= 220; + + bool hasX64Tag = ContainsAny(name, ["x64", "amd64", "x86_64", "win64", "64-bit", "64bit"]); + bool hasArm64Tag = ContainsAny(name, ["arm64", "aarch64"]); + bool hasX86Tag = !hasX64Tag && ContainsAny(name, ["x86", "i386", "ia32", "win32", "32-bit", "32bit"]); + + score += RuntimeInformation.OSArchitecture switch + { + Architecture.X64 => hasX64Tag ? 220 : hasArm64Tag ? -140 : hasX86Tag ? -30 : 40, + Architecture.Arm64 => hasArm64Tag ? 260 : hasX64Tag ? -140 : hasX86Tag ? -180 : 30, + Architecture.X86 => hasX86Tag ? 220 : hasX64Tag || hasArm64Tag ? -180 : 30, + _ => 0 + }; + + return score; + } + + private static int GetExtensionPriority(string name, IReadOnlyList preferredExtensions) + { + for (int i = 0; i < preferredExtensions.Count; i++) + { + if (name.EndsWith(preferredExtensions[i], StringComparison.OrdinalIgnoreCase)) + return i; + } + + return -1; + } + + private static bool ContainsAny(string value, IReadOnlyList patterns) + { + return patterns.Any(pattern => value.Contains(pattern, StringComparison.OrdinalIgnoreCase)); + } + + protected override IReadOnlyList GetInstallableVersions_UnSafe(IPackage package) + { + return []; + } + + protected override CacheableIcon? GetIcon_UnSafe(IPackage package) + { + JsonObject? repository = _manager.GetRepositoryInfo(package.Id, Enums.LoggableTaskType.LoadPackageDetails); + string? avatarUrl = repository?["owner"]?["avatar_url"]?.ToString(); + if (!Uri.TryCreate(avatarUrl, UriKind.Absolute, out Uri? iconUrl)) + return null; + + return new CacheableIcon(iconUrl); + } + + protected override IReadOnlyList GetScreenshots_UnSafe(IPackage package) + { + return []; + } + + protected override string? GetInstallLocation_UnSafe(IPackage package) + { + JsonObject? release = _manager.GetLatestReleaseInfo(package.Id, Enums.LoggableTaskType.LoadPackageDetails); + bool canAutoInstall = release is not null && + SelectBestAssetFromRelease(release, autoInstallableOnly: true) is not null; + string downloadDirectory = canAutoInstall + ? GitHubCli.GetDownloadDirectory(package.Id) + : GitHubCli.GetDefaultDownloadDirectory(); + return Directory.Exists(downloadDirectory) ? downloadDirectory : null; + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.GitHubCli/Helpers/GitHubCliPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.GitHubCli/Helpers/GitHubCliPkgOperationHelper.cs new file mode 100644 index 0000000000..f3923a4fc6 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.GitHubCli/Helpers/GitHubCliPkgOperationHelper.cs @@ -0,0 +1,303 @@ +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Diagnostics; +using System.Text.Json.Nodes; +using UniGetUI.Core.Logging; +using UniGetUI.PackageEngine.Classes.Manager.BaseProviders; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Serializable; + +namespace UniGetUI.PackageEngine.Managers.GitHubCliManager; + +internal sealed class GitHubCliPkgOperationHelper : BasePkgOperationHelper +{ + private readonly GitHubCli _manager; + private readonly ConcurrentDictionary _installerContexts = new(StringComparer.OrdinalIgnoreCase); + + private static readonly HashSet SuccessfulInstallerExitCodes = [0, 3010, 1641]; + private static readonly HashSet RetryAsAdminExitCodes = [5, 740, 1925]; + private static readonly HashSet CanceledInstallerExitCodes = [1223, 1602]; + + private sealed class InstallerExecutionContext + { + public string? DownloadedAssetName { get; init; } + public required string DownloadDirectory { get; init; } + public required bool AutoInstallAfterDownload { get; init; } + public required bool RunAsAdministrator { get; init; } + public required bool InteractiveInstallation { get; init; } + } + + private enum InstallerLaunchResult + { + Success, + Failure, + Canceled, + RetryAsAdmin + } + + public GitHubCliPkgOperationHelper(GitHubCli manager) : base(manager) + { + _manager = manager; + } + + protected override IReadOnlyList _getOperationParameters( + IPackage package, + InstallOptions options, + OperationType operation) + { + if (!GitHubCli.IsValidRepositoryId(package.Id)) + throw new InvalidDataException($"Repository id \"{package.Id}\" is not valid"); + + string contextKey = GetContextKey(package.Id, operation); + List parameters; + + if (operation is OperationType.Install or OperationType.Update) + { + JsonObject? release = _manager.GetLatestReleaseInfo(package.Id, LoggableTaskType.OtherTask); + string? autoInstallableAssetName = TryGetAutoInstallableAssetName(release); + string? selectedAssetName = autoInstallableAssetName ?? TryGetPreferredAssetName(release); + + bool autoInstallAfterDownload = !string.IsNullOrWhiteSpace(autoInstallableAssetName); + string downloadDirectory = autoInstallAfterDownload + ? GitHubCli.GetDownloadDirectory(package.Id) + : GitHubCli.GetDefaultDownloadDirectory(); + + parameters = BuildDownloadCommand(package.Id, selectedAssetName, downloadDirectory); + + _installerContexts[contextKey] = new InstallerExecutionContext + { + DownloadedAssetName = selectedAssetName, + DownloadDirectory = downloadDirectory, + AutoInstallAfterDownload = autoInstallAfterDownload, + RunAsAdministrator = package.OverridenOptions.RunAsAdministrator is true || options.RunAsAdministrator, + InteractiveInstallation = options.InteractiveInstallation + }; + } + else if (operation is OperationType.Uninstall) + { + _installerContexts.TryRemove(contextKey, out _); + parameters = ["api", "--method", "DELETE", $"/repos/{package.Id}/subscription"]; + } + else + { + throw new InvalidDataException("Invalid package operation"); + } + + parameters.AddRange(operation switch + { + OperationType.Update => options.CustomParameters_Update, + OperationType.Uninstall => options.CustomParameters_Uninstall, + _ => options.CustomParameters_Install + }); + + return parameters; + } + + private static string GetContextKey(string repositoryId, OperationType operation) + => $"{operation}:{repositoryId}"; + + private static string? TryGetAutoInstallableAssetName(JsonObject? release) + { + if (release is null) + return null; + + JsonObject? asset = GitHubCliPkgDetailsHelper.SelectBestAssetFromRelease( + release, + autoInstallableOnly: true); + return asset?["name"]?.ToString(); + } + + private static string? TryGetPreferredAssetName(JsonObject? release) + { + if (release is null) + return null; + + JsonObject? asset = GitHubCliPkgDetailsHelper.SelectBestAssetFromRelease( + release, + autoInstallableOnly: false); + return asset?["name"]?.ToString(); + } + + private static List BuildDownloadCommand(string repositoryId, string? assetName, string downloadDirectory) + { + Directory.CreateDirectory(downloadDirectory); + + List command = + [ + "release", + "download", + "--repo", + repositoryId, + "--clobber", + "--dir", + $"\"{downloadDirectory}\"" + ]; + + if (!string.IsNullOrWhiteSpace(assetName)) + { + string sanitizedName = assetName.Replace("\"", ""); + command.AddRange(["--pattern", $"\"{sanitizedName}\""]); + } + else + { + // Repository has a release but no named assets. Download release source archive. + command.AddRange(["--archive", "zip"]); + } + + return command; + } + + private static string? GetDownloadedInstallerPath(string downloadDirectory, string expectedAssetName) + { + if (!Directory.Exists(downloadDirectory)) + return null; + + string expectedPath = Path.Join(downloadDirectory, expectedAssetName); + return File.Exists(expectedPath) ? expectedPath : null; + } + + private static ProcessStartInfo BuildInstallerStartInfo( + string installerPath, + string extension, + InstallerExecutionContext context) + { + ProcessStartInfo startInfo; + + if (extension == ".msi") + { + string args = context.InteractiveInstallation + ? $"/i \"{installerPath}\"" + : $"/i \"{installerPath}\" /qn /norestart"; + + startInfo = new ProcessStartInfo + { + FileName = "msiexec.exe", + Arguments = args, + }; + } + else + { + startInfo = new ProcessStartInfo + { + FileName = installerPath, + }; + } + + startInfo.UseShellExecute = true; + startInfo.CreateNoWindow = true; + startInfo.WorkingDirectory = Path.GetDirectoryName(installerPath) + ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + if (context.RunAsAdministrator) + startInfo.Verb = "runas"; + + return startInfo; + } + + private static InstallerLaunchResult LaunchInstaller( + IPackage package, + InstallerExecutionContext context) + { + if (string.IsNullOrWhiteSpace(context.DownloadedAssetName)) + return InstallerLaunchResult.Failure; + + string? installerPath = GetDownloadedInstallerPath(context.DownloadDirectory, context.DownloadedAssetName); + if (string.IsNullOrWhiteSpace(installerPath)) + { + Logger.Warn($"Downloaded release asset {context.DownloadedAssetName} was not found for {package.Id}"); + return InstallerLaunchResult.Failure; + } + + string extension = Path.GetExtension(installerPath).ToLowerInvariant(); + ProcessStartInfo startInfo = BuildInstallerStartInfo(installerPath, extension, context); + Logger.Info($"Launching installer for package {package.Id}: {startInfo.FileName} {startInfo.Arguments}".Trim()); + + try + { + using Process installerProcess = Process.Start(startInfo) + ?? throw new InvalidOperationException($"Failed to start installer process for {package.Id}"); + + installerProcess.WaitForExit(); + int exitCode = installerProcess.ExitCode; + Logger.Info($"Installer process for {package.Id} exited with code {exitCode}"); + + if (SuccessfulInstallerExitCodes.Contains(exitCode)) + return InstallerLaunchResult.Success; + + if (CanceledInstallerExitCodes.Contains(exitCode)) + return InstallerLaunchResult.Canceled; + + if (!context.RunAsAdministrator && RetryAsAdminExitCodes.Contains(exitCode)) + return InstallerLaunchResult.RetryAsAdmin; + + return InstallerLaunchResult.Failure; + } + catch (Win32Exception ex) when (ex.NativeErrorCode == 1223) + { + Logger.Warn($"Installer launch for {package.Id} was canceled by the user"); + return InstallerLaunchResult.Canceled; + } + catch (Win32Exception ex) when (!context.RunAsAdministrator && ex.NativeErrorCode == 740) + { + Logger.Warn($"Installer for {package.Id} requires elevation"); + return InstallerLaunchResult.RetryAsAdmin; + } + catch (Exception ex) + { + Logger.Error($"Failed to launch installer for package {package.Id}"); + Logger.Error(ex); + return InstallerLaunchResult.Failure; + } + } + + protected override OperationVeredict _getOperationResult( + IPackage package, + OperationType operation, + IReadOnlyList processOutput, + int returnCode) + { + string contextKey = GetContextKey(package.Id, operation); + + if (operation is OperationType.Uninstall) + { + _installerContexts.TryRemove(contextKey, out _); + bool removedTrackedRepository = GitHubCli.RemoveTrackedRepository(package.Id); + return returnCode == 0 || removedTrackedRepository + ? OperationVeredict.Success + : OperationVeredict.Failure; + } + + _installerContexts.TryRemove(contextKey, out InstallerExecutionContext? context); + + if (returnCode != 0) + return OperationVeredict.Failure; + + if (context is not null && context.AutoInstallAfterDownload) + { + InstallerLaunchResult installerResult = LaunchInstaller(package, context); + if (installerResult is InstallerLaunchResult.Canceled) + return OperationVeredict.Canceled; + + if (installerResult is InstallerLaunchResult.RetryAsAdmin) + { + package.OverridenOptions.RunAsAdministrator = true; + return OperationVeredict.AutoRetry; + } + + if (installerResult is InstallerLaunchResult.Failure) + return OperationVeredict.Failure; + } + + string? latestVersion = _manager.GetLatestReleaseTag(package.Id, Enums.LoggableTaskType.OtherTask); + if (string.IsNullOrWhiteSpace(latestVersion)) + { + latestVersion = operation is OperationType.Update && package.IsUpgradable + ? package.NewVersionString + : package.VersionString; + } + + GitHubCli.TrackRepository(package.Id, latestVersion); + return OperationVeredict.Success; + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.GitHubCli/UniGetUI.PackageEngine.Managers.GitHubCli.csproj b/src/UniGetUI.PackageEngine.Managers.GitHubCli/UniGetUI.PackageEngine.Managers.GitHubCli.csproj new file mode 100644 index 0000000000..8cb9ba8743 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.GitHubCli/UniGetUI.PackageEngine.Managers.GitHubCli.csproj @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs b/src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs index 5df649111b..b11df6abcd 100644 --- a/src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs +++ b/src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs @@ -4,6 +4,7 @@ using UniGetUI.PackageEngine.Managers.CargoManager; using UniGetUI.PackageEngine.Managers.ChocolateyManager; using UniGetUI.PackageEngine.Managers.DotNetManager; +using UniGetUI.PackageEngine.Managers.GitHubCliManager; using UniGetUI.PackageEngine.Managers.NpmManager; using UniGetUI.PackageEngine.Managers.PipManager; using UniGetUI.PackageEngine.Managers.PowerShell7Manager; @@ -33,8 +34,9 @@ public static class PEInterface public static readonly PowerShell7 PowerShell7 = new(); public static readonly Cargo Cargo = new(); public static readonly Vcpkg Vcpkg = new(); + public static readonly GitHubCli GitHubCli = new(); - public static readonly IPackageManager[] Managers = [WinGet, Scoop, Chocolatey, Npm, Pip, Cargo, Vcpkg, DotNet, PowerShell, PowerShell7]; + public static readonly IPackageManager[] Managers = [WinGet, Scoop, Chocolatey, Npm, Pip, Cargo, Vcpkg, GitHubCli, DotNet, PowerShell, PowerShell7]; public static void LoadLoaders() { diff --git a/src/UniGetUI.PackageEngine.PackageEngine/UniGetUI.PackageEngine.PEInterface.csproj b/src/UniGetUI.PackageEngine.PackageEngine/UniGetUI.PackageEngine.PEInterface.csproj index 92ae6427b0..4c49dce160 100644 --- a/src/UniGetUI.PackageEngine.PackageEngine/UniGetUI.PackageEngine.PEInterface.csproj +++ b/src/UniGetUI.PackageEngine.PackageEngine/UniGetUI.PackageEngine.PEInterface.csproj @@ -14,6 +14,7 @@ + diff --git a/src/UniGetUI.sln b/src/UniGetUI.sln index d71e0676e3..91e8823af2 100644 --- a/src/UniGetUI.sln +++ b/src/UniGetUI.sln @@ -67,6 +67,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UniGetUI.PackageEngine.Mana EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UniGetUI.PackageEngine.Managers.Npm", "UniGetUI.PackageEngine.Managers.Npm\UniGetUI.PackageEngine.Managers.Npm.csproj", "{0FFA3F96-A68A-453F-A5FE-0C281EC371C7}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UniGetUI.PackageEngine.Managers.GitHubCli", "UniGetUI.PackageEngine.Managers.GitHubCli\UniGetUI.PackageEngine.Managers.GitHubCli.csproj", "{6D597AD0-D3CC-4FBB-9CF0-83F15125D48D}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Generic", "Generic", "{9BF1CD59-1A2C-4023-9C8D-171DCB728078}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UniGetUI.PackageEngine.Managers.Generic.NuGet", "UniGetUI.PackageEngine.Managers.Generic.NuGet\UniGetUI.PackageEngine.Managers.Generic.NuGet.csproj", "{5FA79592-DE5B-46FF-9E05-34A2E72A7AF7}" @@ -217,6 +219,10 @@ Global {0FFA3F96-A68A-453F-A5FE-0C281EC371C7}.Debug|x64.Build.0 = Debug|x64 {0FFA3F96-A68A-453F-A5FE-0C281EC371C7}.Release|x64.ActiveCfg = Release|x64 {0FFA3F96-A68A-453F-A5FE-0C281EC371C7}.Release|x64.Build.0 = Release|x64 + {6D597AD0-D3CC-4FBB-9CF0-83F15125D48D}.Debug|x64.ActiveCfg = Debug|x64 + {6D597AD0-D3CC-4FBB-9CF0-83F15125D48D}.Debug|x64.Build.0 = Debug|x64 + {6D597AD0-D3CC-4FBB-9CF0-83F15125D48D}.Release|x64.ActiveCfg = Release|x64 + {6D597AD0-D3CC-4FBB-9CF0-83F15125D48D}.Release|x64.Build.0 = Release|x64 {5FA79592-DE5B-46FF-9E05-34A2E72A7AF7}.Debug|x64.ActiveCfg = Debug|x64 {5FA79592-DE5B-46FF-9E05-34A2E72A7AF7}.Debug|x64.Build.0 = Debug|x64 {5FA79592-DE5B-46FF-9E05-34A2E72A7AF7}.Release|x64.ActiveCfg = Release|x64 @@ -309,6 +315,7 @@ Global {740E2894-903D-4B94-9C32-B630593BEB16} = {95168D0B-1B2C-4295-B6D4-D5BAF781B9FA} {D401F706-A182-46E3-A25C-B0BF5AA0D07E} = {95168D0B-1B2C-4295-B6D4-D5BAF781B9FA} {0FFA3F96-A68A-453F-A5FE-0C281EC371C7} = {95168D0B-1B2C-4295-B6D4-D5BAF781B9FA} + {6D597AD0-D3CC-4FBB-9CF0-83F15125D48D} = {95168D0B-1B2C-4295-B6D4-D5BAF781B9FA} {9BF1CD59-1A2C-4023-9C8D-171DCB728078} = {95168D0B-1B2C-4295-B6D4-D5BAF781B9FA} {5FA79592-DE5B-46FF-9E05-34A2E72A7AF7} = {9BF1CD59-1A2C-4023-9C8D-171DCB728078} {09FD3D3A-1EFC-4AEE-B3D7-096D238E0D1A} = {5B9575EA-B4F9-46E4-A75E-59D430779EC7} diff --git a/src/UniGetUI/Pages/SettingsPages/ManagersPages/PackageManager.xaml.cs b/src/UniGetUI/Pages/SettingsPages/ManagersPages/PackageManager.xaml.cs index 310eb6901d..e0d9ce431d 100644 --- a/src/UniGetUI/Pages/SettingsPages/ManagersPages/PackageManager.xaml.cs +++ b/src/UniGetUI/Pages/SettingsPages/ManagersPages/PackageManager.xaml.cs @@ -15,6 +15,7 @@ using UniGetUI.PackageEngine.Managers.CargoManager; using UniGetUI.PackageEngine.Managers.VcpkgManager; using UniGetUI.PackageEngine.Managers.DotNetManager; +using UniGetUI.PackageEngine.Managers.GitHubCliManager; using ExternalLibraries.Clipboard; using CommunityToolkit.WinUI.Controls; using UniGetUI.Interface.Widgets; @@ -61,6 +62,7 @@ protected override void OnNavigatedTo(NavigationEventArgs e) else if (Manager_T == typeof(PowerShell7)) Manager = PEInterface.PowerShell7; else if (Manager_T == typeof(Cargo)) Manager = PEInterface.Cargo; else if (Manager_T == typeof(Vcpkg)) Manager = PEInterface.Vcpkg; + else if (Manager_T == typeof(GitHubCli)) Manager = PEInterface.GitHubCli; else if (Manager_T == typeof(DotNet)) Manager = PEInterface.DotNet; else throw new InvalidCastException("The specified type was not a package manager!"); diff --git a/src/UniGetUI/app.manifest b/src/UniGetUI/app.manifest index 9c8c1daa57..05267e09ff 100644 --- a/src/UniGetUI/app.manifest +++ b/src/UniGetUI/app.manifest @@ -2,7 +2,7 @@ diff --git a/src/WindowsPackageManager.Interop/ExternalLibraries.WindowsPackageManager.Interop.csproj b/src/WindowsPackageManager.Interop/ExternalLibraries.WindowsPackageManager.Interop.csproj index 85575c3f5a..6a453fbd4f 100644 --- a/src/WindowsPackageManager.Interop/ExternalLibraries.WindowsPackageManager.Interop.csproj +++ b/src/WindowsPackageManager.Interop/ExternalLibraries.WindowsPackageManager.Interop.csproj @@ -15,7 +15,7 @@ https://github.com/microsoft/CsWinRT/blob/master/nuget/readme.md --> - 10.0.19041.0 + $(TargetPlatformVersion) Microsoft.Management.Deployment From 241b3e9fa9af650620d04618460d15e91fa2f22b Mon Sep 17 00:00:00 2001 From: Symb0x76 <9667434@qq.com> Date: Thu, 12 Feb 2026 12:14:14 +0800 Subject: [PATCH 2/3] chore: Update valid version numbers --- UniGetUI.iss | 4 ++-- scripts/BuildNumber | 2 +- src/SharedAssemblyInfo.cs | 6 +++--- src/UniGetUI.Core.Data/CoreData.cs | 4 ++-- src/UniGetUI/app.manifest | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/UniGetUI.iss b/UniGetUI.iss index 9c9ceffbf5..df39298e94 100644 --- a/UniGetUI.iss +++ b/UniGetUI.iss @@ -1,7 +1,7 @@ ; Script generated by the Inno Setup Script Wizard. ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! -#define MyAppVersion "ghTest" +#define MyAppVersion "3.3.7" #define MyAppName "UniGetUI" #define MyAppPublisher "Martí Climent" #define MyAppURL "https://github.com/marticliment/UniGetUI" @@ -23,7 +23,7 @@ AppPublisher={#MyAppPublisher} AppPublisherURL="https://www.marticliment.com/unigetui/" AppSupportURL={#MyAppURL} AppUpdatesURL={#MyAppURL} -VersionInfoVersion=1.1.1.0 +VersionInfoVersion=3.3.7.1 DefaultDirName="{autopf64}\UniGetUI" DisableProgramGroupPage=yes DisableDirPage=no diff --git a/scripts/BuildNumber b/scripts/BuildNumber index 9d07aa0df5..c9c41087e2 100644 --- a/scripts/BuildNumber +++ b/scripts/BuildNumber @@ -1 +1 @@ -111 \ No newline at end of file +114 \ No newline at end of file diff --git a/src/SharedAssemblyInfo.cs b/src/SharedAssemblyInfo.cs index ae1ff443b6..b312c45e12 100644 --- a/src/SharedAssemblyInfo.cs +++ b/src/SharedAssemblyInfo.cs @@ -6,7 +6,7 @@ [assembly: AssemblyTitle("UniGetUI")] [assembly: AssemblyDefaultAlias("UniGetUI")] [assembly: AssemblyCopyright("2025, Martí Climent")] -[assembly: AssemblyVersion("1.1.1.0")] -[assembly: AssemblyFileVersion("1.1.1.0")] -[assembly: AssemblyInformationalVersion("ghTest")] +[assembly: AssemblyVersion("3.3.7.1")] +[assembly: AssemblyFileVersion("3.3.7.1")] +[assembly: AssemblyInformationalVersion("3.3.7")] [assembly: SupportedOSPlatform("windows10.0.19041")] diff --git a/src/UniGetUI.Core.Data/CoreData.cs b/src/UniGetUI.Core.Data/CoreData.cs index 5688e0a72c..dbab2c57d1 100644 --- a/src/UniGetUI.Core.Data/CoreData.cs +++ b/src/UniGetUI.Core.Data/CoreData.cs @@ -7,8 +7,8 @@ public static class CoreData { private static int? __code_page; public static int CODE_PAGE { get => __code_page ??= GetCodePage(); } - public const string VersionName = "ghTest"; // Do not modify this line, use file scripts/apply_versions.py - public const int BuildNumber = 111; // Do not modify this line, use file scripts/apply_versions.py + public const string VersionName = "3.3.7"; // Do not modify this line, use file scripts/apply_versions.py + public const int BuildNumber = 114; // Do not modify this line, use file scripts/apply_versions.py public const string UserAgentString = $"UniGetUI/{VersionName} (https://marticliment.com/unigetui/; contact@marticliment.com)"; diff --git a/src/UniGetUI/app.manifest b/src/UniGetUI/app.manifest index 05267e09ff..d2ddfc50ff 100644 --- a/src/UniGetUI/app.manifest +++ b/src/UniGetUI/app.manifest @@ -2,7 +2,7 @@ From fc10664d01a4e30a881d4e3a3a6360d7b8461b5b Mon Sep 17 00:00:00 2001 From: Symb0x76 <9667434@qq.com> Date: Thu, 12 Feb 2026 16:02:40 +0800 Subject: [PATCH 3/3] refactor: Simplify repository ID handling and update enum usage in GitHub CLI manager --- .../GitHubCli.cs | 36 +++++++++---------- .../Helpers/GitHubCliPkgDetailsHelper.cs | 9 ++--- .../Helpers/GitHubCliPkgOperationHelper.cs | 2 +- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/UniGetUI.PackageEngine.Managers.GitHubCli/GitHubCli.cs b/src/UniGetUI.PackageEngine.Managers.GitHubCli/GitHubCli.cs index 70622e0c55..d7a4a34a43 100644 --- a/src/UniGetUI.PackageEngine.Managers.GitHubCli/GitHubCli.cs +++ b/src/UniGetUI.PackageEngine.Managers.GitHubCli/GitHubCli.cs @@ -14,6 +14,7 @@ using UniGetUI.PackageEngine.ManagerClasses.Classes; using UniGetUI.PackageEngine.ManagerClasses.Manager; using UniGetUI.PackageEngine.PackageClasses; +using Enums = UniGetUI.PackageEngine.Enums; namespace UniGetUI.PackageEngine.Managers.GitHubCliManager; @@ -87,17 +88,16 @@ protected override IReadOnlyList FindPackages_UnSafe(string query) } List packages = []; - foreach (JsonNode? repoNode in repos) + foreach (string repositoryId in repos + .Select(repoNode => repoNode?["nameWithOwner"]?.ToString() + ?? repoNode?["full_name"]?.ToString()) + .Where(IsValidRepositoryId) + .Select(repositoryId => repositoryId!)) { - string? repositoryId = repoNode?["nameWithOwner"]?.ToString() - ?? repoNode?["full_name"]?.ToString(); - if (!IsValidRepositoryId(repositoryId)) - continue; - - if (!HasReleases(repositoryId!, Enums.LoggableTaskType.FindPackages)) + if (!HasReleases(repositoryId, Enums.LoggableTaskType.FindPackages)) continue; - packages.Add(new Package(repositoryId!, repositoryId!, UnknownVersion, DefaultSource, this)); + packages.Add(new Package(repositoryId, repositoryId, UnknownVersion, DefaultSource, this)); } return packages; } @@ -185,11 +185,10 @@ protected override IReadOnlyList GetInstalledPackages_UnSafe() public override IReadOnlyList FindCandidateExecutableFiles() { var candidates = CoreTools.WhichMultiple("gh.exe"); - foreach (string candidate in CoreTools.WhichMultiple("gh")) - { - if (!candidates.Contains(candidate, StringComparer.OrdinalIgnoreCase)) - candidates.Add(candidate); - } + foreach (string candidate in CoreTools.WhichMultiple("gh") + .Where(candidate => !candidates.Contains(candidate, StringComparer.OrdinalIgnoreCase))) + candidates.Add(candidate); + return candidates; } @@ -337,12 +336,11 @@ private IReadOnlyList GetWatchedRepositories() if (pageNode is not JsonArray repoArray) continue; - foreach (JsonNode? repoNode in repoArray) - { - string? fullName = repoNode?["full_name"]?.ToString(); - if (IsValidRepositoryId(fullName)) - repositories.Add(fullName!); - } + repositories.UnionWith( + repoArray + .Select(repoNode => repoNode?["full_name"]?.ToString()) + .Where(IsValidRepositoryId) + .Select(fullName => fullName!)); } return [.. repositories]; diff --git a/src/UniGetUI.PackageEngine.Managers.GitHubCli/Helpers/GitHubCliPkgDetailsHelper.cs b/src/UniGetUI.PackageEngine.Managers.GitHubCli/Helpers/GitHubCliPkgDetailsHelper.cs index 331f49a354..7da18b223d 100644 --- a/src/UniGetUI.PackageEngine.Managers.GitHubCli/Helpers/GitHubCliPkgDetailsHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.GitHubCli/Helpers/GitHubCliPkgDetailsHelper.cs @@ -3,6 +3,7 @@ using UniGetUI.Core.IconEngine; using UniGetUI.Core.Tools; using UniGetUI.PackageEngine.Classes.Manager.BaseProviders; +using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Interfaces; namespace UniGetUI.PackageEngine.Managers.GitHubCliManager; @@ -39,8 +40,8 @@ protected override void GetDetails_UnSafe(IPackageDetails details) if (!GitHubCli.IsValidRepositoryId(repositoryId)) throw new InvalidDataException($"Repository id \"{repositoryId}\" is not valid"); - JsonObject? repository = _manager.GetRepositoryInfo(repositoryId, Enums.LoggableTaskType.LoadPackageDetails); - JsonObject? release = _manager.GetLatestReleaseInfo(repositoryId, Enums.LoggableTaskType.LoadPackageDetails); + JsonObject? repository = _manager.GetRepositoryInfo(repositoryId, LoggableTaskType.LoadPackageDetails); + JsonObject? release = _manager.GetLatestReleaseInfo(repositoryId, LoggableTaskType.LoadPackageDetails); details.ManifestUrl = new Uri($"https://github.com/{repositoryId}/releases"); @@ -226,7 +227,7 @@ protected override IReadOnlyList GetInstallableVersions_UnSafe(IPackage protected override CacheableIcon? GetIcon_UnSafe(IPackage package) { - JsonObject? repository = _manager.GetRepositoryInfo(package.Id, Enums.LoggableTaskType.LoadPackageDetails); + JsonObject? repository = _manager.GetRepositoryInfo(package.Id, LoggableTaskType.LoadPackageDetails); string? avatarUrl = repository?["owner"]?["avatar_url"]?.ToString(); if (!Uri.TryCreate(avatarUrl, UriKind.Absolute, out Uri? iconUrl)) return null; @@ -241,7 +242,7 @@ protected override IReadOnlyList GetScreenshots_UnSafe(IPackage package) protected override string? GetInstallLocation_UnSafe(IPackage package) { - JsonObject? release = _manager.GetLatestReleaseInfo(package.Id, Enums.LoggableTaskType.LoadPackageDetails); + JsonObject? release = _manager.GetLatestReleaseInfo(package.Id, LoggableTaskType.LoadPackageDetails); bool canAutoInstall = release is not null && SelectBestAssetFromRelease(release, autoInstallableOnly: true) is not null; string downloadDirectory = canAutoInstall diff --git a/src/UniGetUI.PackageEngine.Managers.GitHubCli/Helpers/GitHubCliPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.GitHubCli/Helpers/GitHubCliPkgOperationHelper.cs index f3923a4fc6..d2fd8d0af1 100644 --- a/src/UniGetUI.PackageEngine.Managers.GitHubCli/Helpers/GitHubCliPkgOperationHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.GitHubCli/Helpers/GitHubCliPkgOperationHelper.cs @@ -289,7 +289,7 @@ protected override OperationVeredict _getOperationResult( return OperationVeredict.Failure; } - string? latestVersion = _manager.GetLatestReleaseTag(package.Id, Enums.LoggableTaskType.OtherTask); + string? latestVersion = _manager.GetLatestReleaseTag(package.Id, LoggableTaskType.OtherTask); if (string.IsNullOrWhiteSpace(latestVersion)) { latestVersion = operation is OperationType.Update && package.IsUpgradable